The event loop

Predict the order things run in, and stop blocking the one thread that draws the page.

15of 18
≈ 13Min read
5Parts

One thread, one loop

"Run to completion" is the rule everything else follows from. A task cannot be paused, so a function that takes 300 ms means 300 ms with no rendering, no scrolling and no response to a click. The page is not busy — it is frozen.

what the browser does, forever
take the next task from the queue
  run it to completion            ← nothing can interrupt it
drain the microtask queue         ← all of them, including ones added along the way
maybe render                      ← style, layout, paint — about 60 times a second
repeat

Tasks and microtasks are not the same queue

the order this prints
console.log("1");
setTimeout(() => console.log("4"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("2");

// 1, 2, 3, 4 — every time, in every browser
QueueContainsDrained
tasks (macrotasks)setTimeout, events, fetch completion, message handlersone per loop turn
microtasks.then, await continuations, queueMicrotask, MutationObserverall of them, after every task
A microtask that queues a microtask runs in the same drain

The queue is emptied until it is empty, not "the ones that were there". A recursive queueMicrotask starves the page completely — no rendering, no timers, no way out. A recursive setTimeout does not, because each one waits for the next turn.

Where rendering fits

The browser renders at most once per turn, and only if something changed and it is not behind. That is why several style changes in one function produce one paint, and why measuring geometry in the middle forces an early layout — the thrashing from the rendering lesson.

and the API that is in step with it
requestAnimationFrame(update);   // just before the next paint, ~16.7 ms apart
// perfect for animation: one call per frame, paused in a background tab

setTimeout(update, 16);          // close, but drifts, and keeps running when hidden

Not blocking the thread

A "long task" is anything over 50 ms, and it is what the INP metric measures: the gap between a tap and the screen changing. Splitting a 300 ms job into six 50 ms pieces does not make it faster — it makes the page responsive while it happens, which is what people actually feel.

four ways to keep the page alive
// 1 — break the work into turns
for (const chunk of chunks) {
  await new Promise((r) => setTimeout(r, 0));   // yield: the browser can paint
  process(chunk);
}

// 2 — do it when the browser is idle
requestIdleCallback(() => prefetchNextPage());

// 3 — move it off the thread entirely
const worker = new Worker("/parse.js");
worker.postMessage(bigThing);

// 4 — do less: virtualise the list, paginate, or ask the server

await does not mean parallel

await pauses your function and hands the thread back — it does not run anything in parallel. Starting the requests first and awaiting them together is the difference between 300 ms and 100 ms, and it is the most common easy win in code that talks to an API.

a sequence, and a set
// three round trips, one after another
const a = await fetch("/a");
const b = await fetch("/b");
const c = await fetch("/c");

// one round trip's worth of waiting
const [a, b, c] = await Promise.all([fetch("/a"), fetch("/b"), fetch("/c")]);

Practice