gap·map

Event loop

[ junior depth ]

What the event loop is

JavaScript runs your code on a single thread: one thing at a time, on the call stack. The browser around it is not so limited. Timers keep ticking and network requests complete while your function runs, and the user keeps clicking. The event loop connects the two worlds: it decides when the callbacks from those outside events get their turn on the single thread.

The starting mental model:

  1. Synchronous code runs first and to completion. Nothing can interrupt a running function.
  2. setTimeout callbacks, click handlers, and network callbacks go into a queue.
  3. When the call stack is empty, the event loop takes the next callback from the queue and runs it.

Why setTimeout(fn, 0) doesn't run immediately

console.log("a");
setTimeout(() => console.log("b"), 0);
console.log("c");
// a, c, b

setTimeout(fn, 0) doesn't mean "run now". It means "put fn in the queue as soon as possible". The queued callback still waits for all current synchronous code to finish, which is why c prints before b.

Why promises run before setTimeout

Callbacks from promises (.then, and code after await) go into a separate queue with higher priority. When the sync code ends, promise callbacks run before any setTimeout callback:

setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
// promise, timeout

At this level the rule of thumb is enough: sync code, then promise callbacks, then timers and events. Why the queues are split, and what each queue is called, is the middle-level story.

[ middle depth ]

Microtasks vs macrotasks

The two queues from the junior level have names:

  • Macrotasks (a.k.a. tasks): setTimeout / setInterval callbacks, DOM events, message events. One macrotask is processed per loop iteration.
  • Microtasks: promise reactions (.then/catch/finally), await continuations, queueMicrotask, MutationObserver. The microtask queue is drained completely after the current task, before the next macrotask.

"Drained completely" is the load-bearing rule. After every macrotask (and after the initial script), the engine runs microtasks until the queue is empty, including microtasks queued by other microtasks.

async/await desugars to promises

An async function runs synchronously until the first await. The await wraps its operand in a resolved promise and suspends the function; the rest of the function body becomes a microtask continuation.

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
(async () => {
  console.log("4");        // sync — runs immediately
  await null;               // suspend; continuation → microtask queue
  console.log("5");
})();
console.log("6");
// 1, 4, 6, 3, 5, 2

Walk it: the sync pass logs 1, 4, 6 (the async body runs sync until await). The stack empties. Microtasks drain in enqueue order: 3 was queued before the await continuation, so it prints before 5. Only then the macrotask: 2.

Two misconceptions interviewers screen for:

  • "await blocks the thread." It suspends the function; the thread keeps going, which is why 6 prints before 5.
  • "async functions start later." The body starts synchronously; only the post-await part is deferred.

When the browser repaints

The browser can repaint between macrotasks, never between microtasks. One iteration runs roughly: a task, then a full microtask drain, then (maybe) style/layout/paint. This is why a long chain of promise callbacks freezes the page just as badly as a long for loop: the paint never gets a slot.

[ senior depth ]

Microtask starvation

Because the microtask queue drains fully before anything else proceeds, a microtask that queues another microtask can starve the loop:

function loop() { Promise.resolve().then(loop); }
loop(); // no timer ever fires again, no paint ever happens

The same code with setTimeout(loop, 0) is harmless: macrotasks yield to rendering and other tasks between iterations. This asymmetry is the practical reason the split exists. Microtasks guarantee "run before anything else observable happens" semantics (state consistency); macrotasks guarantee fairness.

Which scheduling API to use

  • queueMicrotask(fn): explicit microtask; same queue as promise reactions, FIFO with them.
  • requestAnimationFrame(fn): runs before the next paint, not on the task queues; the right place for visual updates (guaranteed at most once per frame).
  • setTimeout(fn, 0): next task, after possible rendering; also subject to clamping (nested timers clamp to ~4ms; background tabs throttle far more aggressively).
  • scheduler.postTask() / requestIdleCallback: priority-aware and idle scheduling for cooperative chunking of long work.

The decision rule: when state must be consistent before anything else runs, use a microtask; for a visual update, rAF; to yield to the browser and then continue, a macrotask or scheduler.yield().

Node.js differences

Same language rules, different loop implementation (libuv) with phases: timers → pending callbacks → poll → check (setImmediate) → close callbacks. Between every phase transition, microtasks drain, and process.nextTick has its own queue that runs before promise microtasks. Hence the classic: setTimeout(fn, 0) vs setImmediate(fn) ordering is non-deterministic in the main script but deterministic inside an I/O callback, where setImmediate wins because the check phase follows poll.

One event loop iteration, step by step

Take one task, run it to completion, drain the microtask queue entirely (including newly queued ones), then a rendering opportunity (rAF, style, layout, paint), then the next task. Every "predict the output" puzzle and every UI-freeze bug reduces to this sequence plus two facts: async bodies run sync until await, and microtasks starve everything downstream.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

unlocks

more JavaScript