The Event Loop
Why JS is single-threaded but not slow.
JavaScript runs on a single thread. One thing at a time, in order. And yet a webpage can fetch data, animate a button, respond to your clicks, and wait for a timer all at once. The thing that makes that possible is the event loop, and once you understand it you understand 90% of weird async bugs.
The four moving parts
The runtime has exactly four pieces you need to picture:
- Call stack- the list of functions currently running. Last in, first out. When it's empty, JS is idle.
- Web APIs - things provided by the browser (or Node), not the language itself:
setTimeout,fetch, the DOM. They run outside the JS thread. - Task queue (macrotasks) - completed Web API jobs waiting their turn: timers, I/O, click events.
- Microtask queue - a higher-priority queue for promise callbacks (
.then,awaitcontinuations) andqueueMicrotask.
A picture in code
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// Output: 1, 4, 3, 2Step by step:
console.log("1")runs synchronously.setTimeouthands the callback to the browser. The callback eventually lands in the task queue.Promise.resolve().then(...)schedules its callback on the microtask queue.console.log("4")runs synchronously.- The stack is empty. The loop drains microtasks first →
3. - Then it takes one task →
2.
setTimeout(fn, 0) is not zero
Even with a delay of 0, the callback has to go through the task queue. It will only run after the current call stack empties and after every microtask scheduled before it.
setTimeout(() => console.log("timer"), 0);
for (let i = 0; i < 5; i++) {
Promise.resolve().then(() => console.log("micro " + i));
}
console.log("sync");
// Output:
// sync
// micro 0
// micro 1
// micro 2
// micro 3
// micro 4
// timersetTimeout to a minimum (4ms in most cases, more if the tab is backgrounded). Use queueMicrotask or Promise.resolve().then(...) for "run after this stack but before any timer."The classic interview puzzle
async function main() {
console.log("a");
setTimeout(() => console.log("b"), 0);
await Promise.resolve();
console.log("c");
Promise.resolve().then(() => console.log("d"));
console.log("e");
}
main();
console.log("f");
// Output: a, f, c, e, d, bWalking through it:
"a"runs (sync).setTimeoutschedules"b"as a task.await Promise.resolve()pausesmainand schedules its continuation as a microtask. Control returns to the caller."f"runs (still sync, outside main).- The stack is empty. Drain microtasks → continuation of main runs, logs
"c". - Schedules
"d"as a microtask, logs"e". - Drain microtasks again →
"d". - One task →
"b".
Why blocking the loop is bad
Because there's one thread, a long synchronous loop freezes everything: animations stop, clicks queue up, the tab feels dead. That's why heavy CPU work belongs in a Web Worker, why fetch is async, and why you should never write a long synchronous for loop on the main thread.
Predict the order, then run it
Try to guess the output before running. Then change the code: swap a promise for a setTimeout, add an await, see what moves.
Quiz
Which queue runs first when the call stack is empty?
Recap
- JS is single-threaded. The event loop coordinates between the call stack and queues.
- Microtasks (promises,
queueMicrotask) drain fully between every macrotask (timers, I/O). setTimeout(fn, 0)is "run after this stack and all current microtasks," not "run immediately."- Blocking the main thread freezes the UI. Use async APIs for I/O, Workers for heavy CPU.