Chapter 13 β€” Event Loop & Task Queues (Visual Guide)

πŸ“– Definition

JavaScript runs your code on one thread β€” it can do only one thing at a time. The event loop is the manager that decides what runs next: your normal code first, then waiting callbacks from two queues β€” the microtask queue (promises) and the task queue (timers, clicks, network events).

One line to remember: Run the current code β†’ run ALL microtasks β†’ (browser paints) β†’ run ONE task β†’ repeat.

🍳 A Simple Analogy β€” One Chef in a Kitchen

Kitchen JavaScript
One chef who can cook only one dish at a time Call stack β€” runs one function at a time
Kitchen helpers: oven timer, delivery person Web APIs / Node APIs β€” timers, network, file system work in the background
VIP order board β€” always served first, all of them Microtask queue β€” promise callbacks, await
Regular order board β€” one order at a time Task (macrotask) queue β€” setTimeout, clicks, I/O
Manager who tells the chef what to cook next, only when the chef is free Event loop

The chef never stops in the middle of a dish. When a dish is finished, the manager first hands over every VIP order, and only then one regular order.

πŸ–ΌοΈ The Big Picture

Call stack, Web APIs, microtask queue, task queue and the event loop Call Stack console.log() handleClick() main script runs ONE thing at a time Web APIs / Node APIs setTimeout timer fetch / network DOM events (click) Microtask Queue Promise .then() code after await VIP: ALL run first Task Queue setTimeout callback click handler ONE per loop turn Event Loop β‘  hand off β‘‘ resolves β‘‘ ready β‘’ first β‘£ then one β‘€ push to stack (only when empty)
β‘  Async work is handed to the browser/Node. β‘‘ When it finishes, its callback waits in a queue. β‘’ The event loop empties the microtask queue first, β‘£ then takes one task, β‘€ and pushes it onto the call stack only when the stack is empty.

πŸ” The Five Parts

Part What it is Examples
Call stack Where functions run. Last in, first out. Your functions, console.log
Web APIs / Node APIs Work done outside JavaScript, in the background Timers, fetch, DOM events, file reading
Microtask queue High-priority callbacks. All of them run before anything else. .then/.catch/.finally, code after await, queueMicrotask
Task (macrotask) queue Normal callbacks. One runs per loop turn. setTimeout, setInterval, clicks, network events, setImmediate (Node)
Event loop Keeps checking: "Is the stack empty? Then what's next?" β€”

πŸ” One Turn of the Event Loop

One turn of the event loop: run one task, run all microtasks, render, pick next task 1. Run ONE task script, timer, click 2. Run ALL microtasks until queue is empty 3. Render rAF β†’ layout β†’ paint 4. Next task from task queue repeat forever (browser renders only when needed, ~60 times a second)
The whole algorithm. Notice step 2 empties the entire microtask queue β€” even microtasks added while it runs.

The rules in words:

  1. Run the current script (or task) until the call stack is empty. Nothing can interrupt it.
  2. Run every microtask. If a microtask adds another microtask, that runs too, before moving on.
  3. The browser may repaint the screen (and run requestAnimationFrame callbacks).
  4. Take one task from the task queue and go back to step 1.

πŸ’» Example 1 β€” The Classic Question

console.log("1: start");

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

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

console.log("4: end");

Output: 1: start β†’ 4: end β†’ 3: promise β†’ 2: timeout

Step by step

Step What happens Call stack Microtask queue Task queue Output so far
1 Run line 1 log("1") β€” β€” 1
2 setTimeout hands the timer to the browser (0 ms). When it expires, its callback joins the task queue setTimeout β€” timeout cb 1
3 Promise is already resolved β†’ its .then callback joins the microtask queue then promise cb timeout cb 1
4 Run line 4 log("4") promise cb timeout cb 1 4
5 Script finished β†’ stack empty β†’ run all microtasks promise cb β€” timeout cb 1 4 3
6 Microtasks empty β†’ take one task timeout cb β€” β€” 1 4 3 2
Snapshot right after the script finishes: stack empty, one microtask and one task waiting Call Stack (empty β€” script done) Microtask Queue log("3: promise") Task Queue log("2: timeout") Console 1: start 4: end
The moment after the last line runs. The green (microtask) box goes next, then the orange (task) box. That's why the promise prints before the timeout.

Key insight: setTimeout(fn, 0) does not mean "run now". It means "run after at least 0 ms, once the stack is empty and all microtasks are done".

πŸ’» Example 2 β€” async / await

async function load() {
  console.log("A1: inside load");
  await null;                        // everything after await becomes a microtask
  console.log("A2: after await");
}

console.log("S: start");
setTimeout(() => console.log("T: timeout"), 0);
load();
Promise.resolve().then(() => console.log("P: promise"));
console.log("E: end");

Output: S β†’ A1 β†’ E β†’ A2 β†’ P β†’ T

Step Event Microtask queue Task queue Output
1 S logs β€” β€” S
2 Timer registered β€” T S
3 load() runs synchronously until await β†’ A1 logs β€” T S A1
4 await pauses load; the rest of the function is queued A2 T S A1
5 .then queued A2, P T S A1
6 E logs, script ends A2, P T S A1 E
7 Run all microtasks in order β€” T S A1 E A2 P
8 Run one task β€” β€” S A1 E A2 P T

An async function runs normally (synchronously) until the first await. Only the code after await is delayed.

πŸ’» Example 3 β€” Microtasks Added by Microtasks

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

Promise.resolve().then(() => {
  console.log("p1");
  Promise.resolve().then(() => console.log("p2"));   // added while microtasks are running
});

Output: p1 β†’ p2 β†’ timeout New microtasks join the same queue that is being emptied, so p2 still runs before the timeout.

πŸ’» Example 4 β€” Microtasks Run After Each Task

setTimeout(() => {
  console.log("t1");
  Promise.resolve().then(() => console.log("promise inside t1"));
}, 0);
setTimeout(() => console.log("t2"), 0);

Output: t1 β†’ promise inside t1 β†’ t2 After task t1, the loop empties the microtask queue before picking task t2.

πŸ“‹ Which Queue Does It Go To?

API Queue
Promise.then / catch / finally 🟒 Microtask
Code after await 🟒 Microtask
queueMicrotask(fn) 🟒 Microtask
MutationObserver 🟒 Microtask
setTimeout, setInterval 🟠 Task
DOM events (click, keydown, input) 🟠 Task
Network / I/O callbacks 🟠 Task
MessageChannel / postMessage 🟠 Task
setImmediate (Node only) 🟠 Task (check phase)
requestAnimationFrame 🎨 Before the next paint (render step)
new Promise(executor) β€” the executor itself ⚑ Runs immediately (synchronous!)
process.nextTick (Node only) 🟣 Its own queue, runs even before promise microtasks

⚠️ Common Mistakes

1. Blocking the call stack freezes everything

button.addEventListener("click", () => {
  const end = Date.now() + 3000;
  while (Date.now() < end) {}        // 3 s of busy work β€” page can't scroll, click or repaint
  console.log("done");
});

While the stack is busy, the event loop can't run anything β€” no clicks, no timers, no painting.

Fix: split work into chunks so the loop can breathe, or move it to a Web Worker (browser) / worker thread (Node).

async function processInChunks(items, chunkSize = 500) {
  for (let i = 0; i < items.length; i += chunkSize) {
    items.slice(i, i + chunkSize).forEach(processItem);
    await new Promise((r) => setTimeout(r, 0));     // give the event loop a turn
  }
}

2. Endless microtasks also freeze the page

function loop() { Promise.resolve().then(loop); }   // ❌ microtask queue never empties
loop();                                              // page never repaints, timers never run

3. Expecting setTimeout(fn, 1000) to run in exactly 1 second

The delay is a minimum. If the stack is busy for 3 seconds, the callback runs after 3 seconds. Browsers also clamp nested timers to at least ~4 ms.

4. Thinking new Promise is async

new Promise((resolve) => {
  console.log("executor");   // runs immediately
  resolve();
}).then(() => console.log("then"));
console.log("after");
// executor β†’ after β†’ then

🟩 Node.js Is Slightly Different

Node uses the same idea but its event loop has phases: timers β†’ pending callbacks β†’ poll (I/O) β†’ check (setImmediate) β†’ close callbacks. Between callbacks it drains the process.nextTick queue first, then promise microtasks. Details are in the Node.js: Event Loop chapter.

🧩 Practice β€” Predict the Output

Puzzle 1

console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
queueMicrotask(() => console.log(4));
console.log(5);
Show answer

1 5 3 4 2 β€” sync logs first (1, 5), then both microtasks in the order they were queued (3, 4), then the timer task (2).

Puzzle 2

async function f() {
  console.log("f1");
  await g();
  console.log("f2");
}
async function g() { console.log("g"); }

f();
console.log("end");
Show answer

f1 g end f2 β€” f runs synchronously and calls g (also synchronous inside), then await pauses f; end logs; finally the rest of f runs as a microtask.

Puzzle 3

setTimeout(() => console.log("A"), 10);
setTimeout(() => console.log("B"), 0);
const start = Date.now();
while (Date.now() - start < 50) {}     // block for 50 ms
console.log("C");
Show answer

C B A β€” nothing runs until the 50 ms loop ends. By then both timers have expired; B's timer (0 ms) expired before A's (10 ms), so B runs first.

Puzzle 4

const p = new Promise((resolve) => {
  console.log("1");
  setTimeout(() => { console.log("2"); resolve(); }, 0);
});
p.then(() => console.log("3"));
console.log("4");
Show answer

1 4 2 3 β€” the executor runs immediately (1), then 4. The promise isn't resolved yet, so no microtask exists. The timer task logs 2 and resolves the promise, which queues the .then microtask, and that runs right after the task (3).

Puzzle 5

button.addEventListener("click", () => {
  Promise.resolve().then(() => console.log("micro"));
  console.log("listener");
});
button.click();          // called from code
console.log("after click");
Show answer

listener after click micro β€” button.click() from script calls the listener synchronously, so the stack isn't empty until after "after click"; only then do microtasks run. (A real user click would print listener micro because each user-triggered listener runs as its own task.)

🌍 Real-World Impact

  • Understanding the loop explains why a heavy for loop makes the UI freeze, and why moving it to a Web Worker fixes it.
  • It explains why React state updates and promise callbacks appear "later" than surrounding code.
  • In Node.js APIs, one CPU-heavy request (big JSON parse, image resize) blocks all users β€” the same rule applies on the server.

🎯 Likely Interview Questions

  1. What is the event loop? β€” A mechanism that runs queued callbacks when the call stack is empty: all microtasks first, then one task, repeatedly.
  2. Microtask vs macrotask? β€” Microtasks (promises, await, queueMicrotask) all run right after the current code; macrotasks (setTimeout, events, I/O) run one per loop turn.
  3. Why does Promise.then run before setTimeout(fn, 0)? β€” The microtask queue is emptied before the next task is taken.
  4. Is JavaScript single-threaded? Then how is it asynchronous? β€” Your JS runs on one thread; timers, network and I/O run in the browser/Node background, and their callbacks are queued for the loop.
  5. Does setTimeout(fn, 0) run immediately? β€” No; after the current code and all microtasks finish, with at least the given delay.
  6. What runs synchronously in an async function? β€” Everything up to the first await.
  7. How can the event loop get blocked? β€” Long synchronous work or an endless chain of microtasks; nothing else can run, the page freezes.
  8. Where does requestAnimationFrame run? β€” In the render step, just before the browser paints.
  9. How is Node's event loop different? β€” It has phases (timers, poll, check…) and a process.nextTick queue that runs before promise microtasks.

← Promises | Index | Next: Debounce / Throttle β†’