Runtime and performanceAsync machineryPart 1
The Event Loop You Think You Know
Microtasks, rendering opportunities and starvation — what the loop actually does between your callbacks, in the browser and in Node.

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, taskThe 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, bawait 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 organizes the loop around libuv’s phases instead. One iteration visits them in a fixed order:
pending callbacks — some deferred system operations.
idle, prepare — internal to libuv; nothing you schedule lands here.
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.
timers — expired setTimeout and setInterval callbacks.
Timers last is the part most diagrams still get wrong. Node’s own event loop guide draws them first and then corrects itself: “Starting with libuv 1.45.0 (Node.js 20), timers are run after the poll phase in each event loop iteration. In earlier versions, timers were run before polling.” libuv still runs the timers once before entering the loop, for compatibility, which is why the old position survives in the picture.
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. Two hundred runs of that
file on Node 24.18.0 printed immediate first 196 times and timeout first 4
times — a majority, not a rule, and nothing to build on. Inside an I/O callback
it is deterministic: setImmediate always wins, because check runs immediately
after poll, while the timers phase does not come round until after check and
close.
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 utilization — 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.
FAQ
Frequently asked
Does await yield even when the value is already settled?
Yes, and that is the whole of it. Measured on Node 24.20.0: an async function that logs a, awaits null, then logs b, called before a synchronous log of c, prints a, c, b. Awaiting registers the rest of the function as a microtask, and there is no fast path that skips the queue when the value needed no waiting.
Is process.nextTick a microtask?
It has its own queue, and that queue is drained before promise microtasks — with one exception that looks like a bug the first time you hit it. Measured on Node 24.20.0: at the top level of an ES module, a .then callback runs before a process.nextTick one. Module evaluation is itself a promise job, so its microtask checkpoint finishes before the tick queue is visited. In CommonJS, and inside any callback in either module system, nextTick runs first.


