
Most explanations of the event loop stop at “callbacks go in a queue, the loop takes them out”. That model predicts the output of about half the programs you will actually write. The half it gets wrong is the half where something is slow for no visible reason.
Here is the version that predicts the rest. Every ordering below was run on Node 24.18.0; the browser half follows the HTML specification’s event loop processing model, which is what makes it the same in every engine.
One task, then every microtask
The loop’s unit of work is a task — one setTimeout callback, one event
listener firing, one parsed chunk of HTML. Tasks are what the queue in the
simple model holds.
Microtasks are a separate queue with a different rule. After a task finishes, the loop drains the microtask queue to empty before doing anything else. Not one microtask; all of them. If a microtask queues another microtask, that one is drained in the same pass.
setTimeout(() => console.log('task'), 0);
Promise.resolve()
.then(() => console.log('micro 1'))
.then(() => console.log('micro 2'));
console.log('sync');
// sync, micro 1, micro 2, task
The synchronous code is itself part of a task, so both microtasks run before the loop is willing to look at the timer. That ordering is not an implementation detail — the HTML specification requires it, and every engine follows it.
Anything promise-shaped joins that queue: .then callbacks, everything after
an await, queueMicrotask, MutationObserver callbacks. Framework
schedulers ride it too — a signals implementation flushes its
effects in exactly this window.
async function f() {
console.log('a');
await null; // suspends here; the rest becomes a microtask
console.log('b');
}
f();
console.log('c');
// a, c, b
await null is not a no-op. Awaiting anything — including a value that is
already settled — yields to the microtask queue.
The step nobody mentions: rendering
In a browser the loop has a third move. Between tasks, it may update the
rendering: run requestAnimationFrame callbacks, recalculate style, lay out,
paint.
May, not must. The browser skips it when nothing needs painting or when the display is not ready for a new frame, which on a 60Hz screen is about every 16.7ms.
This is where the two queues stop being trivia. The browser cannot paint during a task, and it cannot paint during a microtask drain. A 200ms task freezes the page for 200ms, and so does 200ms of chained microtasks.
// Blocks paint until it finishes — no frames, no input response.
function process(items) {
for (const item of items) expensive(item);
}
// Also blocks paint, despite looking asynchronous.
function processAsync(items, i = 0) {
if (i >= items.length) return;
expensive(items[i]);
queueMicrotask(() => processAsync(items, i + 1));
}
The second version never returns control to the loop. Each microtask queues the next one, and the drain rule says the loop keeps going until the queue is empty — which it never is. That is microtask starvation, and it is worse than the blocking loop it was meant to fix: same freeze, harder to find.
Swap queueMicrotask for setTimeout(..., 0) and the behavior changes
completely. Each step becomes its own task, so the loop gets a rendering
opportunity between them. The page stays responsive; the work takes longer in
total, because of the minimum clamp browsers apply to nested timers.
if ('scheduler' in globalThis && 'yield' in scheduler) {
await scheduler.yield(); // yield to the loop, keep priority
}
scheduler.yield() is the purpose-built version — it yields without going to
the back of the timer queue. It is available in Chromium-based browsers; check
current support before depending on it, and keep the setTimeout path as a
fallback.
Node: phases instead of frames
Node has no rendering step, so it organises the loop around libuv’s phases instead. One iteration visits them in a fixed order:
timers — expired setTimeout and setInterval callbacks.
pending callbacks — some deferred system operations.
poll — retrieve I/O events, run their callbacks; this is where the loop
blocks and waits when there is nothing else to do.
check — setImmediate callbacks.
close callbacks — close events on handles.
Node’s own event loop guide documents the order; the observable consequences below were checked against it.
Microtasks are drained between individual callbacks, not merely between phases.
Node also has a second, higher-priority queue: process.nextTick callbacks run
before promise microtasks, and a recursive nextTick starves the loop exactly
the way a recursive queueMicrotask does.
One exception is worth knowing because it looks like a contradiction: at the top
level of an ES module, a .then callback runs before a process.nextTick one.
Module evaluation is itself already a promise job, so the microtask checkpoint it
belongs to finishes before the tick queue is visited. In CommonJS, and inside any
callback in either module system, nextTick wins as documented.
The classic Node puzzle follows from the phase order:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
At the top level, the output is not deterministic — it depends on how long
process startup took relative to the timer’s threshold. Inside an I/O callback
it is deterministic: setImmediate always wins, because the poll phase is
immediately followed by check, while timers have to wait for the next
iteration.
require('node:fs').readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// immediate, then timeout — every time.
Seeing it rather than reasoning about it
The loop is observable, and reasoning about ordering from first principles is the slower way to get an answer.
In a browser, PerformanceObserver with entryTypes: ['longtask'] reports
every task over 50ms, with a start time and duration. The performance panel’s
flame chart shows the same tasks against the frames that did or did not get
painted, which is usually enough to identify the offender by name.
In Node, perf_hooks monitors the loop directly:
import { monitorEventLoopDelay } from 'node:perf_hooks';
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
setInterval(() => console.log('p99 delay:', histogram.percentile(99)), 5000);
Loop delay is how long callbacks wait beyond their scheduled time. A rising p99 means something synchronous is holding the loop, and it is a far better production signal than CPU utilisation — a process at 40% CPU with 300ms of loop delay is a process failing to serve requests.
Once you have found the task that is too long, the next question is why — and that is usually a question about what the engine is doing with the code inside it.
The rules worth memorising
Four, and they cover most of it:
Microtasks drain completely between tasks. Yielding to a microtask is not yielding at all.
The browser can only paint between tasks. Anything that does not return to the loop blocks a frame, whatever it is made of.
setTimeout(fn, 0) yields; queueMicrotask(fn) does not. That is the entire
difference, and it is the one that matters.
In Node, phase order decides setTimeout versus setImmediate, and it is only
predictable inside an I/O callback.
Everything else — timer clamping, nextTick priority, the exact conditions for
a rendering opportunity — is detail you can look up when a specific program
disagrees with you. These four are the model.