Anzal AnsariAnzal Ansari

The JavaScript Event Loop: the one mental model that unlocks all async

How JavaScript handles async without blocking — call stack, microtasks, macrotasks, and why the order matters more than you think.

JavaScript is single-threaded. One line at a time, one call stack, one thing happening at any moment.

And yet — it handles network requests, timers, user clicks, and file reads without freezing. How?

The answer is the event loop. Once you have this model in your head, every async behaviour in JavaScript — Promises, async/await, setTimeout, React's batching, stale closures — stops being surprising.

The four layers

┌──────────────────────────────────────────────────┐
│                  CALL STACK                      │
│  Your synchronous code. Runs to completion.      │
│  Nothing else can run while this has frames.     │
└──────────────────────┬───────────────────────────┘
                       │ empty? event loop checks:

┌──────────────────────────────────────────────────┐
│              MICROTASK QUEUE                     │
│  Promise.then / .catch / .finally                │
│  queueMicrotask()                                │
│  Drained COMPLETELY before touching macrotasks   │
└──────────────────────────────────────────────────┘
                       │ only when empty:

┌──────────────────────────────────────────────────┐
│              MACROTASK QUEUE                     │
│  setTimeout, setInterval, I/O callbacks          │
│  ONE task per loop turn, then check microtasks   │
└──────────────────────────────────────────────────┘
                       ↑ callbacks arrive from:
┌──────────────────────────────────────────────────┐
│            WEB / NODE APIS                       │
│  Timers, fetch, file I/O — run outside JS        │
│  Push callback to correct queue when done        │
└──────────────────────────────────────────────────┘

The loop itself is simple: is the call stack empty? drain all microtasks, then run one macrotask, repeat.

The question everyone gets asked

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');

What prints, and in what order?

Walk through it:

  1. console.log('1') — synchronous, runs now. Prints 1.
  2. setTimeout(...) — schedules the callback in the macrotask queue.
  3. Promise.resolve().then(...) — schedules the callback in the microtask queue.
  4. console.log('4') — synchronous, runs now. Prints 4.
  5. Call stack empties. Event loop drains microtasks: prints 3.
  6. Event loop picks one macrotask: prints 2.

Output: 1, 4, 3, 2.

The key rule: microtasks always run before the next macrotask, regardless of when they were scheduled.

await is .then() in a trench coat

async function run() {
  console.log('A');
  await Promise.resolve();
  console.log('B');
}
run();
console.log('C');

// Output: A, C, B

When run() hits await, it suspends and returns control to the caller. The code after awaitconsole.log('B') — is scheduled as a microtask. So C runs first (synchronous), then B runs (microtask).

This is the whole mechanic behind async/await. Every line after an await is a .then() callback.

The stale closure trap in React

This is where the event loop bites React developers:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // ❌ stale: count is always 0
    }, 1000);
    return () => clearInterval(id);
  }, []);
}

The setInterval callback is a macrotask. By the time it runs, it holds a closure over count from when the effect ran — which is 0. Every tick writes 0 + 1 = 1.

The fix doesn't require understanding closures deeply. It requires knowing that the updater function gets the current state, bypassing the closure:

setCount(prev => prev + 1); // ✅ no closure dependency

One trap that breaks everything: microtask starvation

function loop() {
  Promise.resolve().then(loop);
}
loop();

This creates an infinite chain of microtasks. The microtask queue never empties. Macrotasks — including setTimeout, browser rendering, user events — never get a turn. The tab freezes.

This is why setTimeout(fn, 0) is sometimes used to deliberately yield — putting work in the macrotask queue lets the browser render between chunks.

The one-line version

Sync runs first. Then all microtasks (Promises). Then one macrotask (timers/I/O). Repeat.

Every async JavaScript puzzle is just that rule applied.


Part of my interview prep series — writing to learn. Next: Closures and why they're the source of half of all React bugs.