Lesson 23 of 28
The Event Loop
setTimeout(fn, 0) is never zero, promise callbacks always jump the queue, and one runaway microtask can freeze a tab. Here is the model that explains it.
A tab locks up. The spinner freezes mid-rotation, clicks pile up unanswered, and eight seconds later every one of them lands at once. Nothing crashed. One function simply never handed control back. Everything in this lesson follows from understanding what it was supposed to hand control back to.
The parts, and who owns them
Three pieces do the work. The heap holds objects. The call stack is the frame-by-frame record of what is executing right now. The event loop takes queued work and pushes it onto the stack once the stack is empty.
Only the first two belong to the language. ECMAScript defines jobs and a job
queue, but it never defines a loop that runs them. That belongs to the host:
the HTML specification in a browser, libuv in Node. setTimeout is not
JavaScript. Neither is fetch, postMessage, or the DOM. They are host APIs
that do work elsewhere and hand results back by queueing a job.
This is not pedantry. It is the reason identical code prints in a different
order under Node than under Chrome, and the reason setImmediate exists in one
and not the other.
Two queues, and the rule between them
There are two kinds of queued work, and the difference between them explains most surprising output.
Macrotasks — the spec calls them tasks — are the coarse units of work.
setTimeout and setInterval callbacks, I/O completions, message events
from postMessage or a worker, and UI events such as click and scroll.
Microtasks are the fine ones. Every .then, .catch and .finally
callback, the continuation after every await, anything passed to
queueMicrotask, and MutationObserver callbacks.
The rule that matters:
After the running task finishes and the stack empties, the loop drains the microtask queue completely before taking the next macrotask — including microtasks queued by other microtasks while draining.
One task, then all microtasks, then the next task. Never half.
Reading an ordering example
console.log("script start");
setTimeout(() => console.log("timeout 1"), 0);
Promise.resolve()
.then(() => console.log("promise 1"))
.then(() => console.log("promise 2"));
queueMicrotask(() => console.log("microtask"));
setTimeout(() => console.log("timeout 2"), 0);
console.log("script end");
// script start
// script end
// promise 1
// microtask
// promise 2
// timeout 1
// timeout 2Line by line. The script is itself a task, so both console.log calls at the
top level run first: script start, script end. Along the way, two timer
callbacks were handed to the host, the first .then was queued as a microtask
because the promise was already resolved, and queueMicrotask was queued
behind it.
The stack empties. The loop drains microtasks in order. promise 1 prints, and
its return value resolves the second .then, which is appended to the queue
that is currently draining. microtask prints next because it was queued
first. Then promise 2, which only existed once promise 1 had run.
The queue is now empty, so the loop takes a macrotask: timeout 1, then
another turn of the loop for timeout 2. A zero-millisecond timer loses to a
promise every time, and no amount of shortening the delay changes that. They
are in different queues.
await is the same mechanism wearing a different face:
async function work() {
console.log("A");
await null;
console.log("B");
}
work();
console.log("C");
// A
// C
// Bawait null does not wait for anything. It still suspends the function and
schedules the rest of it as a microtask, so C gets there first. Every await
in a hot loop is a queue round trip, which is why for (const id of ids) await load(id) is so much slower than it looks.
Move the queueMicrotask call above the promise chain and the middle three
lines reorder. Nothing else moves.
Starving the loop
Because the queue must drain completely, a microtask that queues itself is not a slow loop. It is a dead tab.
function spin() {
Promise.resolve().then(spin);
}
spin();
setTimeout(() => console.log("never printed"), 0);The stack never grows, so there is no stack overflow to point at in a crash report. There is no long function to spot in a flame chart either — each microtask returns immediately. The page simply stops rendering and stops responding to input, because rendering and input both need the loop to reach the next task.
In production this shows up as a recursive retry written with promises, or a
state store whose subscriber synchronously triggers another update. The same
shape written with setTimeout is merely wasteful; written with promises it is
fatal.
Rendering sits between tasks
The browser does not repaint whenever you touch the DOM. Once per frame, between tasks, it runs its rendering steps: animation frame callbacks, then style, layout, paint, composite.
requestAnimationFrame schedules a callback into that block, just before style
and layout are calculated. That is why it is the correct place for visual
updates. Writing to the DOM there means your change is measured and painted in
the same frame, with no intermediate state and no torn animation.
requestIdleCallback sits at the other end, running only when the browser has
spare time after a frame. Its deadline.timeRemaining() is capped at 50ms and
will usually be much less. Use it for genuinely discardable work — sending
analytics, warming a cache — and always pass a timeout option so it still
runs on a busy page. Safari held out for years and only shipped it recently, so
check your support floor before relying on it.
For scheduling application work rather than paint work, scheduler.postTask()
now offers real priorities (user-blocking, user-visible, background), and
scheduler.yield() lets a long task hand control back mid-way without losing
its place in line. Both beat the old setTimeout(fn, 0) trick where available.
Why zero is not zero
setTimeout(fn, 0) schedules a task for at least zero milliseconds from now,
which is a floor, not a promise. Three things push it out.
The current task has to finish first, and the microtask queue after it. Then
HTML applies the nesting clamp: once a timer is scheduled from inside
another timer, more than five levels deep, any delay under 4ms is raised to
4ms. A setInterval(fn, 0) therefore settles at about 250 iterations per
second, not thousands.
Backgrounded tabs are throttled harder — timers drop to roughly once per
second, and Chrome's intensive throttling takes hidden pages down to once per
minute after a few minutes. Code that assumes a timer keeps ticking while the
user is on another tab will drift badly. If you need elapsed time, read
Date.now() rather than counting ticks.
Single threaded, true and misleading
One thread runs your JavaScript, so two lines of your code never execute simultaneously and you never need a mutex around a plain object. That part is true.
What is misleading is the implication that the browser is single threaded. It is not. Network requests, timers, parsing, compositing, and much of the raster work happen on other threads; your callback is merely the notification. And you can have more than one JavaScript thread, using workers — separate loops, separate heaps, talking by message.
The practical statement is narrower and more useful: there is exactly one main thread, everything competes for it, and any work you put on it delays everything else on it.
Node runs a different loop
Node's loop is organised into phases that run in a fixed order, the important
ones being timers (setTimeout, setInterval), poll (I/O), and
check (setImmediate). A setImmediate callback runs after the poll phase
of the current turn; a setTimeout(fn, 0) waits for the next timers phase, so
from inside an I/O callback setImmediate always wins — while at the top level
the race between them is genuinely non-deterministic.
Node also has a queue the browser does not: process.nextTick. It drains
before the promise microtask queue, which makes it the sharpest starvation tool
in the runtime. Since Node 11, microtasks drain between individual timers and
immediates rather than after the whole phase, which is what brought Node's
ordering in line with the browser for ordinary promise code.
What to remember
- The loop belongs to the host, not to the language.
setTimeoutis not part of JavaScript. - One macrotask, then the microtask queue in full, then the next macrotask.
- Promise callbacks and
queueMicrotaskalways beatsetTimeout, whatever the delay. - A self-queueing microtask freezes the tab without a long function or a stack overflow to show for it.
requestAnimationFrameruns inside the rendering steps;requestIdleCallbackruns on leftovers.- Nested timers clamp to 4ms, and hidden tabs are throttled far harder than that.
Check yourself
4 questions · pass 3/4 to unlock Iterators and Generators
1.A script logs "a", schedules
setTimeout(() => log("b"), 0), then callsPromise.resolve().then(() => log("c")), then logs "d". What prints?2.A microtask queues another microtask, which queues another, without end. What happens to a
setTimeoutcallback that is already due?3.Why does
setTimeout(fn, 0)fire roughly every 4ms inside a chain of nested timeouts?4.When does a
requestAnimationFramecallback run?
4 left to answer