intermediate
async
runtime
browser
· 12 min

How does the JavaScript event loop actually work?

TL;DR
The event loop is a coordinator that moves tasks from queues (macrotask/microtask) onto the call stack whenever the stack is empty. Microtasks drain fully between each macrotask.

The moving parts

JavaScript is single-threaded, but the runtime (browser or Node) gives it a call stack, a heap, a set of task queues, and a loop that shuttles work between them. The event loop is not part of the JS engine (V8) — it lives in the host.

  • Call stack — synchronous frames currently executing.
  • Macrotask queue — setTimeout, setInterval, I/O callbacks, UI events.
  • Microtask queue — Promise callbacks, queueMicrotask, MutationObserver.
  • Render step (browser only) — style, layout, paint between macrotasks if needed.

The algorithm, plainly

  1. Pop the next macrotask, run it to completion on the stack.
  2. Drain ALL microtasks queued during step 1 (microtasks can enqueue more microtasks — they still run now).
  3. If in a browser and a frame is due, run the render steps.
  4. Go back to step 1.

Classic trick question

console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');
What's the output order?

Output: A, D, C, B. The synchronous lines run first. The Promise callback (microtask) drains before the setTimeout (macrotask) — even though both were scheduled with 'zero' delay.

Step through an event loop

Enqueue tasks and watch the stack, microtask queue, and macrotask queue update.

Interactive
Call stack
idle
Microtask queue
(empty)
Macrotask queue
(empty)
Execution log
Run a tick to see output…

Follow-ups interviewers love

  • Why does `await` yield like a microtask? (because it resumes via Promise.then)
  • What happens if a microtask throws? (it becomes an unhandledrejection)
  • Can a long microtask chain block rendering? (yes — starvation is real)