· 6 min read · by

The JavaScript event loop is one very busy waiter

Single-threaded, yet somehow serving every table. Call stack, microtasks, tasks, and why your setTimeout(0) is late.

  • JavaScript
  • Node.js
  • Fundamentals
Share
Cooks in white uniforms working at a steel counter in a busy restaurant kitchen
Photo by Jinhan Moon on Unsplash

One waiter, many tables

Picture a restaurant with exactly one waiter. That waiter is JavaScript.

The waiter can only do one thing at a time: take an order, carry a plate, or refill water. But the restaurant still serves forty tables, because the waiter never stands in the kitchen waiting for the food. They hand the order to the kitchen, go serve someone else, and come back when the bell rings.

The kitchen is the browser (or Node.js). The bell is the event loop. That's the whole idea. The rest is details, and the details are where the interview questions live.

The four characters

Call stackmain()handleClick()renderList()one thing at a timeWeb APIs / libuvwaits, then queues the callbackhand offMicrotask queue (VIP)Promise.thenqueueMicrotaskTask queue (everyone else)setTimeout cbclick eventEventloop1st2ndwhen the stack is empty, run the next callback
One call stack, a helper (Web APIs / libuv) doing the waiting, two queues, and the loop that feeds the stack.
  • Call stack: what the waiter is doing right now. Functions get pushed on when called and popped off when they return.
  • Web APIs / libuv: the kitchen. Timers, fetch, file reads and DOM events are handled outside your thread. When they're done, they drop a callback into a queue.
  • Microtask queue: the VIP line. Promise callbacks (.then, await continuations) and queueMicrotask go here.
  • Task queue (often called the macrotask queue): everyone else. setTimeout, setInterval, UI events, I/O callbacks.

The one rule that explains everything

When the call stack is empty, the event loop does this, over and over:

  1. Run all microtasks, until the microtask queue is empty (including new ones added along the way).
  2. Let the browser render, if it wants to.
  3. Take one task from the task queue and run it.
  4. Go back to step 1.

So promises are VIPs: after every single task, the entire VIP line gets served before the next regular customer.

The classic quiz

What does this print?

console.log("1");

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

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

console.log("4");

If you said 1 2 3 4, welcome, you're among friends. The real answer is 1 4 3 2.

logs "1"synclogs "4"synclogs "3"microtasklogs "2"tasktime →
Synchronous code first, then every microtask, then the next task from the queue.
  • 1 and 4 are plain synchronous code, so they run first, top to bottom.
  • The promise callback (3) is a microtask, so it runs as soon as the stack empties.
  • The setTimeout callback (2) is a task. Even with a delay of 0, it waits behind every microtask. setTimeout(fn, 0) means "not sooner than 0 ms", not "right now".

How to freeze a web page in one line

Because there's one waiter, if you make the waiter do a 5-second job, every table waits 5 seconds. Clicks, scrolling, animations: frozen.

button.addEventListener("click", () => {
  const end = Date.now() + 5000;
  while (Date.now() < end) {} // congratulations, the page is now a screenshot
});

Ways to be nicer to your waiter:

  • Split big work into chunks and yield between them (for example with setTimeout or scheduler.yield() where available), so the browser can render and handle input.
  • Move heavy CPU work to a Web Worker (or worker_threads in Node). That's hiring a second waiter who works in another room.
  • Don't create infinite microtasks. A promise chain that keeps scheduling more microtasks can starve rendering, because the VIP line never empties.

A note for Node.js folks

Node has the same idea, powered by libuv, with a few extra phases (timers, I/O polling, setImmediate checks). Two Node-specific things to remember:

  • process.nextTick callbacks run before promise microtasks. It's the VIP line for the VIP line.
  • setImmediate runs in the "check" phase, right after I/O polling. Inside an I/O callback, setImmediate always fires before a setTimeout(fn, 0).

Once this clicks, a lot of "why is this undefined" and "why did this log late" bugs stop being mysterious. They're just a waiter following very strict rules.

Found this useful? Pass it on.

Share