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.
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
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
| Queue | Contains | Drained |
|---|---|---|
| tasks (macrotasks) | setTimeout, events, fetch completion, message handlers | one per loop turn |
| microtasks | .then, await continuations, queueMicrotask, MutationObserver | all of them, after every task |
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.
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.
// 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.
// 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")]);