Notifications

No notifications

How does single-threaded JS handle thousands of concurrent timers, network calls, and clicks? The event loop. This page covers the mental model — call stack, Web APIs / libuv, the macrotask queue, and the higher-priority microtask queue — and finally explains why setTimeout(fn, 0) runs *after* Promise.resolve().then(fn).

On this page

Detailed Theory

# The Event Loop

The pieces

1. Call stack — where synchronous code runs. One thing at a time. 2. Web APIs / Node APIs — setTimeout, fetch, DOM events, file I/O. These run OUTSIDE the JS engine. 3. Macrotask queue (a.k.a. *task queue* / *callback queue*) — setTimeout, setInterval, I/O, UI events. 4. Microtask queue — Promise.then, queueMicrotask, MutationObserver. Higher priority. 5. The loop: when the call stack is empty, - drain ALL microtasks, - then take ONE macrotask, - then drain ALL microtasks again, - render (in browsers, between iterations), - repeat.

A walkthrough

console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve().then(() => console.log("C"));

console.log("D");

Output: A, D, C, B.

Why? 1. A — sync. 2. setTimeout schedules a *macrotask*. 3. Promise.then schedules a *microtask*. 4. D — sync. 5. Stack empty → drain microtasks → C. 6. Take next macrotask → B.

Two queues, one rule

> After every script and every macrotask, the engine drains the ENTIRE microtask queue before doing anything else.

So:

  • Promise.resolve().then(…) runs *before* the next setTimeout(0).
  • A long chain of .then can starve macrotasks (and rendering!) — but it's rare in practice.

queueMicrotask

queueMicrotask(() => console.log("micro"));
Same priority as Promise callbacks — useful when you want "after current code, but before any timer / I/O".

Why setTimeout(fn, 0) isn't really 0

  • HTML5 spec clamps nested timers to ≥4ms after a few levels.
  • Even at 0, it's *a macrotask* — it waits for microtasks AND the current task.
  • For "run after current sync code", queueMicrotask is faster and runs sooner.

I/O example (Node)

const fs = require("fs");

console.log("start"); fs.readFile("./big.txt", () => console.log("file done")); Promise.resolve().then(() => console.log("promise")); console.log("end");

// start, end, promise, file done

fs.readFile is a libuv-backed macrotask (poll phase). Promise wins.

Browser rendering fits in

Per spec, the browser tries to render between macrotasks (~60fps = 16ms budget). If a macrotask is too long, you get jank. requestAnimationFrame schedules a callback right *before* the next paint — better than setTimeout(0) for animations.

requestAnimationFrame vs queueMicrotask vs setTimeout

APIwhen it runs
queueMicrotask(cb)end of current task, before next macrotask
Promise.resolve().then(cb)same as queueMicrotask
setTimeout(cb, 0)next macrotask (+ ≥0/4ms delay)
requestAnimationFrame(cb)just before the next paint (~16ms cycle)
setImmediate(cb) (Node)check phase of current loop iteration

Practical takeaways

  • Don't block the loop with synchronous work. Long loops freeze the UI; offload to a Web Worker.
  • For sequential UI animations, prefer requestAnimationFrame.
  • For "run after the current synchronous chunk", use queueMicrotask (or Promise.resolve().then).
  • Heavy CPU? new Worker() runs JS on another thread.