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