JavaScriptThe Event Loop

The Event Loop

The event loop is the small algorithm at the heart of every JavaScript runtime. It is how a single thread juggles timers, fetches, user input and animation frames without blocking. Once you can picture it, surprising orderings of setTimeout and Promise callbacks stop being surprising — they become predictable.

The cast of characters

The runtime has four pieces working together. None of them are JavaScript code — they live in the browser or in Node.

  • Call stack — the engine pushes a frame for every function call and pops it on return. While the stack has frames, JavaScript is "busy".

  • Heap — where objects, arrays, closures and DOM nodes live. Stack frames hold references into the heap.

  • Task queue (a.k.a. macrotask queue) — callbacks waiting to run: setTimeout, I/O completion, message events.

  • Microtask queue — a higher-priority queue for promise reactions and queueMicrotask callbacks.

Plus, in the browser, a render step the engine wants to run roughly 60 times per second to repaint the page.

The loop, in words

The event loop runs forever. Each turn looks roughly like this:

  1. Pick one task from the task queue and run it to completion (no other task can interrupt it).

  2. Drain the microtask queue — run every microtask, including ones added by other microtasks.

  3. If a render is needed (browser only), run requestAnimationFrame callbacks, then layout and paint.

  4. Go back to step 1.

One task per loop
A single `setTimeout(fn, 0)` callback is one task. While it runs, the call stack fills and empties freely — but no other macrotask runs in between. Microtasks scheduled during the task get drained *before* the next task starts.
The classic ordering example

JS
console.log("1: script start");

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

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

console.log("4: script end");
1: script start
4: script end
3: microtask
2: timeout

Why this order?

  1. The top-level script runs as one task. It logs "1", schedules a timeout (task), schedules a microtask, logs "4".

  2. The task finishes. The event loop drains microtasks before the next task — "3" prints.

  3. Now the queue moves on to the timeout task — "2" prints.

Where each API queues
  • MicrotasksPromise.then/catch/finally, queueMicrotask, MutationObserver callbacks, await continuations.

  • Macrotasks (tasks)setTimeout, setInterval, setImmediate (Node), MessageChannel, I/O completion, UI events like clicks.

  • Animation framerequestAnimationFrame (browser only). Runs once per frame, between tasks and the paint.

See the next page for a deeper comparison of these queues.

A more involved example

JS
console.log("A");

setTimeout(() => {
  console.log("B");
  Promise.resolve().then(() => console.log("C"));
}, 0);

Promise.resolve().then(() => {
  console.log("D");
  setTimeout(() => console.log("E"), 0);
});

console.log("F");
A
F
D
B
C
E

Walking through it:

  1. The script is one task. It logs A, queues a timeout (task), queues a microtask, logs F.

  2. Script finishes — drain microtasks. The microtask logs D and queues another timeout. End of microtasks.

  3. Next task is the first timeout. It logs B and queues a microtask. Task finishes — drain microtasks: C.

  4. Final task is the second timeout. It logs E.

Why setTimeout(fn, 0) is not really 0

setTimeout(fn, 0) says "as soon as possible, but in a new task". It still waits for the current task to finish, all microtasks to drain, and maybe a render. Browsers also enforce a minimum delay (commonly 4 ms for nested timers, longer for background tabs).

JS
for (let i = 0; i < 5; i++) {
  setTimeout(() => console.log("timeout", i), 0);
  Promise.resolve().then(() => console.log("microtask", i));
}
microtask 0
microtask 1
microtask 2
microtask 3
microtask 4
timeout 0
timeout 1
timeout 2
timeout 3
timeout 4

Every microtask scheduled during the synchronous loop runs before any of the timeouts.

Long microtask chains can starve tasks

Because all microtasks drain before the next macrotask, an infinite chain of microtasks can block timeouts and even rendering. This is rare, but a recursive Promise.resolve().then(...) that re-queues itself will starve the UI.

JS
// Don't do this in real code — it locks the page.
function spin() {
  Promise.resolve().then(spin);
}
// spin();   // browser becomes unresponsive
Node has more queues

Node's event loop has several phases in each tick — timers, pending callbacks, idle/prepare, poll (I/O), check (setImmediate), close handlers — with microtasks (and process.nextTick, which is even higher priority) draining between them. The browser model is a useful simplification; Node adds nuance you only need when you debug subtle timing bugs.

If you remember nothing else
After every macrotask, the microtask queue is fully drained. That single rule explains 90% of the surprising orderings you will see between `setTimeout` and `Promise.then`.