Case 2/13

Prefer reading? The full explanation

How Timers Really Run

setTimeout is a minimum, not a promise · Browser

Before this

How the Event Loop Runs — the Call Stack and the Task Queue.

After this you can explain

Why a timer that expires on time can still fire hundreds of milliseconds late.

setTimeout(() => console.log('Timer'), 100);

const start = Date.now();
while (Date.now() - start < 300) {}

console.log('Done blocking');

Output: Done blocking → Timer

Why does the 100ms timer fire at 300ms?

The output is Done blocking → Timer. Nothing went wrong with the timer: it expired at 100ms, exactly as asked. What it could not do was run.

When a timer expires, the browser moves its callback into the task queue. That is the whole of what expiry means. Actually executing the callback requires the event loop to move it onto the call stack, and the event loop only does that when the stack is empty. In this snippet the while loop holds the stack until 300ms, so the callback waits an extra 200ms with nothing to do.

What setTimeout actually guarantees

The delay argument is a lower bound. setTimeout(fn, 100) promises that fn will not run before 100ms have passed, and makes no promise at all about how much later it might run.

Browsers add their own floors on top of that. Timers nested more than five levels deep get clamped to about 4ms regardless of the delay you pass, and background tabs throttle timers heavily, often to once per second. Combined with any long task on the main thread, a "0ms" timer routinely resolves tens of milliseconds later.

Why this breaks animations

Driving animation with setInterval(fn, 16) assumes each frame arrives every 16ms. In practice a single slow task pushes frames late, the browser skips a repaint, and the motion visibly stutters. requestAnimationFrame exists because it schedules against the actual paint cycle instead of a wall-clock guess.

The same reasoning applies to anything timing-sensitive: debounce windows, polling intervals, timeout-based retries. If the main thread can be busy, the timing you wrote down is the best case, not the expected case.

Ordering between timers

The experiment below registers a 100ms timer first and a 0ms timer second. The 0ms callback still runs first, because the task queue is ordered by when each timer expired rather than by when it was registered. Two timers with identical delays keep their registration order, since they expire in that order.

Reading about it is one thing. Watching it run is another.

↑ Run it step by step

In the wild

Where this rule bites real code

The debounce that fires mid-typing

Symptom
Search-as-you-type is debounced at 300ms, yet requests sometimes fire while the user is clearly still typing.
Why
A heavy task (rendering a big result list, parsing a response) blocks the thread past the 300ms mark. The timer expired on time and queued — it delivers the moment the thread frees up, even if a keystroke is about to happen.
Fix
Keep main-thread work small enough that timers stay roughly honest: chunk heavy processing, or move it to a Web Worker. Debounce timing is only as accurate as your longest task.

The setInterval that piles up

Symptom
A 1-second polling interval behaves normally, then after a laggy stretch fires several times almost back-to-back.
Why
setInterval keeps queueing on schedule even when the thread is blocked. Once unblocked, the backlog of callbacks runs in rapid succession.
Fix
Poll with a recursive setTimeout — schedule the NEXT tick only after the current one finishes. The interval becomes "at least", which is what you meant anyway.

Environment

Browser

CI verified

Node.js v22.22.3

Last reviewed

29 July 2026

The animation on this page is a teaching model, not a diagram of browser internals. Real engines use several task queues, optimise aggressively, and do plenty of work these panels do not show. What is guaranteed is the observable ordering: the stated output is produced by actually executing the code in CI, on the runtime named above. Browser-specific behaviour follows the HTML Standard and is not executed in a real browser by CI. How this is verified →