# 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.

- Published: 2026-08-12
- Tags: javascript, performance, nodejs
- Source: https://jsledger.com/blog/the-event-loop-you-think-you-know/
- Language: en-US
- Author: Jonah Vail

---
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](https://html.spec.whatwg.org/multipage/webappapis.html), 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.

```js
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](/blog/signals-are-not-magic/) in exactly this window.

```js
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.

```js
// 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.

```js
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.

:::warning
`requestIdleCallback` is not a general replacement. It runs when the browser
judges itself idle, which under sustained load may be never. It is right for
genuinely optional work and wrong for work that has to finish.
:::

## 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](https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick)
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:

```js
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.

```js
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:

```js
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](/blog/how-v8-decides-to-optimize-your-function/).

## 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.
