runtimes · level 8

Event Loops

Microtasks, macrotasks, await, and the symptom you can never debug from a screenshot.

220 XP

Event Loops

A single-threaded runtime with an event loop can outperform a thread-per-connection server on tens of thousands of concurrent connections — if every code path that might block returns control to the loop quickly. That word, if, is where the bugs live. Understanding the queues a JS engine maintains, and what makes one drain before another, separates "my server randomly hangs" from "my server scales to 50k connections per box."

Analogy

Imagine a single-server diner with a counter. The cook (the event loop) takes one order at a time, finishes it, and only then accepts the next. Most orders are fast — pour coffee, plate a pastry — and the queue moves. But somebody orders an omelette that takes four minutes of full attention. While the cook is whisking, every customer waits. New customers walk in, glance at the line, and leave. The dining room isn't full; the cook is. That's a blocked event loop. A second cook (worker thread) takes the omelette into the kitchen so the front cook can keep serving coffee.

What an event loop is

A pump that:

  1. Picks the next task off the task queue.
  2. Runs it to completion.
  3. Drains all microtasks that the task scheduled.
  4. (Browser only) Updates rendering / runs requestAnimationFrame callbacks.
  5. Goes back to step 1.

Single-threaded. One task at a time. Cooperative. The runtime cannot interrupt your code; you have to return for anyone else to run.

Tasks vs microtasks

Two queues, with very different scheduling behaviour.

Tasks (a.k.a. macrotasks) are the chunky units of work:

  • setTimeout / setInterval callbacks
  • IO callbacks (fs.readFile, socket.on('data'))
  • setImmediate (Node only)
  • MessageChannel / postMessage callbacks
  • DOM event handlers

Microtasks are the fine-grained continuations:

  • Promise .then / .catch / .finally callbacks
  • await resumptions
  • queueMicrotask(fn)
  • MutationObserver callbacks

The headline rule: after every task, the loop drains the entire microtask queue before the next task runs.

console.log("1 sync");

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

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

console.log("4 sync");

// Output:
// 1 sync
// 4 sync
// 3 microtask
// 2 task

Even though the setTimeout was scheduled before the Promise's then, the microtask queue is drained before the loop gets to the next task. This rule is the single most important thing to internalise about JS scheduling.

Why await doesn't yield to the macrotask queue

A common misconception: I added await, so the next CPU-bound work won't block other connections. It does. await only yields when there's a real microtask to wait on — a Promise that's not yet resolved.

async function slowAdd(a, b) {
  let sum = 0;
  for (let i = 0; i < 1e9; i++) sum += i;   // blocks the loop, no yield anywhere
  return a + b;
}

await slowAdd(1, 2);
// Other connections waited the full duration of the loop.

To actually yield, you have to schedule something on the macrotask queue:

async function* chunked(work, chunkSize = 10_000) {
  for (let i = 0; i < work; i += chunkSize) {
    for (let j = 0; j < chunkSize && i + j < work; j++) {
      // do a little
    }
    await new Promise((r) => setTimeout(r, 0));   // genuine yield
  }
}

setTimeout(r, 0) puts a continuation on the task queue. The loop drains microtasks, runs the next task, eventually returns to your continuation. Real concurrency, at the cost of throughput.

For pure CPU work, don't yield in tiny chunks — move it to a worker.

Node's event loop is more layered than the browser's

The Node loop has phases run by libuv, in a fixed order each turn:

   ┌──────────────────────┐
   │   timers             │  setTimeout, setInterval that's due
   ├──────────────────────┤
   │   pending callbacks  │  some system errors (TCP errors)
   ├──────────────────────┤
   │   poll               │  IO callbacks (fs.readFile result)
   ├──────────────────────┤
   │   check              │  setImmediate
   ├──────────────────────┤
   │   close callbacks    │  socket.on('close', …)
   └──────────────────────┘
        ↓ next tick + microtasks drained between phases ↓

process.nextTick(fn) jumps the line — it runs before every other queue, even microtasks. Use sparingly; abusing it starves real work.

Browser event loop adds rendering

Roughly per loop turn in a browser:

  1. Run one task (setTimeout, click handler, etc.).
  2. Drain microtasks.
  3. If the rendering deadline is due:
    • Run requestAnimationFrame callbacks.
    • Recalculate styles / layout / paint.
  4. Repeat.

A microtask that schedules another microtask in a tight loop never returns to step 3. The page freezes until the microtask chain ends. Rare but possible — usually a buggy then chain that resolves with itself.

Symptoms of a blocked event loop

  • Latency spikes that don't correlate with any one slow request — all requests slow at once.
  • Healthchecks succeed (the loop's idle when probed) but production traffic times out.
  • dropped frames warnings in the browser DevTools performance panel.
  • Cross-process timeouts: a healthy peer node is "down" because it didn't get a heartbeat reply in time.

Tools to detect:

  • Node.js: --inspect + Chrome DevTools → Performance flamegraph; or clinic.js (clinic doctor); or event-loop-lag package.
  • Browser: Performance tab → record a trace; long tasks (>50 ms) light up red.

The fix is almost always the same: move CPU-heavy work off the loop. In Node, worker_threads. In browsers, Web Workers. In Python asyncio, loop.run_in_executor with a thread pool or process pool.

When to use which

Need Tool
Many idle connections (chat, websockets) A single event loop is the right answer
CPU-heavy per-request work (image resize, ML inference) Worker pool, off the loop
File IO with a fast disk Event loop + libuv (Node already does this for you)
Database queries Event loop (the network IO is the wait, not the CPU)
Crypto: hashing, signing Worker — even fast crypto is CPU-bound for hundreds of concurrent connections

Common bugs

Forgotten await. A function returns a Promise, the caller doesn't await it. The microtask runs later, errors propagate to process.on('unhandledRejection'), and the original code path looks like it succeeded. Lint with @typescript-eslint/no-floating-promises.

Long synchronous library calls. A 50 ms regex on a slow path. Aggregate it across 1000 req/s and the event loop is at 100% utilisation, dropping requests.

Microtask starvation. A .then chain that re-schedules itself. The loop drains forever, never reaches the next task. Frame rates collapse.

process.nextTick recursion in Node. Same problem at a deeper layer — nextTick fires before microtasks, so a recursive nextTick starves Promises too.

Using setTimeout for "after this completes." setTimeout(fn, 0) doesn't run after all pending work, only after the current task. Use Promise.resolve().then(fn) for "as soon as possible without blocking" or queueMicrotask(fn) for the same.

Practical decisions

  • For an HTTP service: keep the event loop's per-task time under 10 ms p99. If a task takes longer, find a way to break it up or offload it.
  • For a UI: keep the event loop's per-task time under one frame (16 ms at 60 Hz). Anything longer drops frames.
  • For CPU work: workers, not yields. Yielding is a workaround; isolation is the fix.
  • For IO: trust the loop. await fetch() is the right answer; you do not need to "queue" anything yourself.
  • For ordering: write the code so the order of microtasks vs tasks doesn't matter. If it does, you've coupled to scheduling internals and your code will break under a runtime upgrade.