Engineering Notes
Home/Languages/JavaScript/Event Loop
Languages · JavaScript

Event Loop

JavaScript has one call stack and runs on one thread. Yet it can handle timers, network responses, and user input without freezing. The event loop is the mechanism that makes this possible — it coordinates the call stack, a set of task queues, and the browser/Node APIs that handle async work outside of JavaScript itself.

💻 Languages● Advanced⏱ 7 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • The Problem the Event Loop Solves
  • The Four Pieces
  • Walking Through an Example
  • The Golden Rule
  • Why setTimeout(fn, 0) Isn't Instant
Part 1The Mechanism

1The Problem the Event Loop Solves

JavaScript has one call stack and runs on one thread. Yet it can handle timers, network responses, and user input without freezing. The event loop is the mechanism that makes this possible — it coordinates the call stack, a set of task queues, and the browser/Node APIs that handle async work outside of JavaScript itself.

2The Four Pieces

Call stack hands off async work to Web/Node APIs, which feed the macrotask and microtask queues that the event loop drains back into the stack
Call stack hands off async work to Web/Node APIs, which feed the macrotask and microtask queues that the event loop drains back into the stack
text
┌─────────────┐     ┌───────────────────────────┐
│  Call Stack   │     │  Web APIs / Node APIs       │
│  (runs code)  │     │  (setTimeout, fetch, fs...)  │
└──────┬───────┘     └──────────────┬───────────────┘
        │                              │ (when ready)
        │                              ▼
        │                    ┌───────────────────┐
        │                    │  Callback / Task    │
        │                    │  Queues (macro/micro)│
        │                    └─────────┬───────────┘
        ▲                              │
        └──────────── Event Loop ◄─────┘
        (moves queued callbacks onto the stack
          ONLY when the stack is completely empty)
  1. Call Stack — where currently-executing synchronous code lives (see the Call Stack topic).
  2. Web/Node APIs — the runtime environment (not JavaScript itself) that handles timers, I/O, and network requests in the background.
  3. Task Queues — where completed async callbacks wait their turn (split into microtasks and macrotasks — full detail in the next topic).
  4. The Event Loop — a continuously running process that checks: "is the call stack empty? If so, take the next callback from the queue and push it onto the stack."

3Walking Through an Example

js
console.log("1");

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

console.log("3");

Step by step:

  1. console.log("1") runs synchronously on the call stack, prints 1.
  2. setTimeout(...) is handed off to the Web/Node API layer, which starts a 0ms timer. The call stack is NOT blocked waiting for it.
  3. console.log("3") runs synchronously, prints 3.
  4. The synchronous script finishes; the call stack is now empty.
  5. The timer (already expired) has placed its callback in the macrotask queue.
  6. The event loop sees the empty stack, takes the callback from the queue, pushes it onto the stack.
  7. It runs, prints 2.

Result: 1, 3, 2 — never 1, 2, 3, no matter how short the timeout delay is.

4The Golden Rule

The event loop never interrupts running synchronous code. It only ever moves a queued callback onto the stack when the stack is completely empty. This is why a long synchronous operation (see the Call Stack topic) blocks every pending timer and network response, no matter how "urgent" they are.

5Why setTimeout(fn, 0) Isn't Instant

Even with a 0ms delay, the callback still has to:

  1. Wait for the timer to be registered by the Web/Node API layer.
  2. Wait for the CURRENT synchronous code to finish completely.
  3. Wait for the event loop to notice the stack is empty and pull it off the queue.

None of that is instantaneous, and step 2 alone can take arbitrarily long if the current script keeps running.

Part 2Ordering With Multiple Queues

(Full depth on microtasks vs macrotasks is its own topic — this section focuses specifically on how the event loop sequences them.)

1Two Queues, Not One

The event loop actually checks two separate queues, with strict priority: the microtask queue (Promise callbacks, queueMicrotask) is always fully drained before the loop touches the macrotask queue (setTimeout, setInterval, I/O callbacks) again.

js
console.log("1: sync");

setTimeout(() => console.log("4: macrotask"), 0);

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

console.log("2: sync");

Output: 1, 2, 3, 4 — the microtask (Promise callback) always runs before the macrotask (setTimeout), even though the setTimeout line appeared first in the source.

2The Precise Loop Algorithm

text
loop forever:
  1. Run the call stack until empty (all currently synchronous code).
  2. Drain the ENTIRE microtask queue — run every microtask, even
     new ones added WHILE draining (they still get processed
     before moving on).
  3. Pick exactly ONE macrotask from the macrotask queue, run it
     (which itself may add more microtasks — go back to step 2
     immediately after).
  4. (In browsers) possibly re-render the page.
  5. Repeat from step 1.

The critical detail: microtasks are drained completely, including any new microtasks scheduled by earlier ones, before even a single macrotask is allowed to run.

3Microtasks Can Starve Macrotasks

js
function scheduleForever() {
  Promise.resolve().then(scheduleForever); // keeps adding a new microtask, forever
}
scheduleForever();
setTimeout(() => console.log("this may never run"), 0); // starved — the microtask queue never empties

Because the loop must fully drain the microtask queue before touching a macrotask, an infinitely self-scheduling microtask chain can starve all timers and I/O indefinitely — a real (if uncommon) production bug pattern.

4A Complete Worked Example

js
console.log("A");

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

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

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

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

console.log("G");

Trace:

  1. Synchronous: A, G print immediately.
  2. Stack empty → drain microtasks: C runs, schedules the next .then (D) as a NEW microtask; F runs (was already queued); then D runs (queued during step 2, but the queue isn't considered "empty" until it truly has nothing left).
  3. Macrotask queue: B, then E (in the order their timers were registered).

Final order: A, G, C, F, D, B, E.

Under the HoodNode.js's Real Event Loop Has Six Named Phases▾

The simplified "one macrotask queue" model from section 2 is accurate enough for reasoning about ordering in most code, but Node's actual libuv-based event loop is more structured: it cycles through six distinct phases, each with its own queue, in a fixed order, every single iteration.

text
   ┌───────────────────────────┐
┌─▶│           timers            │  setTimeout / setInterval callbacks whose time has come
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │      pending callbacks      │  some system-level callbacks deferred from the previous cycle
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │       idle, prepare          │  internal use only
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │            poll              │  retrieve new I/O events; execute I/O-related callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │            check             │  setImmediate() callbacks specifically
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │        close callbacks        │  e.g. socket.on('close', ...)
│  └─────────────┬─────────────┘
└────────────────┘

Microtasks (Promise callbacks, queueMicrotask) and process.nextTick are drained completely between every single phase transition shown above — not just once per full loop iteration. This is a genuinely important refinement of the simplified model: it's not "sync → all microtasks → one macrotask → repeat," it's "sync → all microtasks → timers phase → all microtasks → pending callbacks phase → all microtasks → ... " and so on through all six phases, every cycle.

Why setImmediate and setTimeout(fn, 0) have a famously unreliable relative order

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

setTimeout(fn, 0) callbacks fire in the timers phase; setImmediate callbacks fire in the check phase — two different phases, several phases apart in the cycle above. Which one logs first depends on how long it took to reach this point from process start (a timer's minimum delay is measured from scheduling, and sub-millisecond timing jitter can flip which phase the loop happens to be in first) — this is why this specific ordering question is infamous for being non-deterministic at the top level of a Node script, while being deterministic inside an I/O callback (where setImmediate reliably fires before a setTimeout(fn, 0) scheduled in the same callback, because the loop is already past the timers phase for that cycle and will hit the check phase before timers comes around again).

⚠️ Common Pitfalls

  1. Assuming setTimeout(fn, 0) beats a Promise .then() — it never does; ALL pending microtasks run first, unconditionally.
  2. Chained .then() calls "jumping the queue" unexpectedly — each .then() adds a NEW microtask when its predecessor resolves; a long .then() chain can interleave with other already-queued microtasks in ways that surprise people expecting strict FIFO across unrelated chains.
  3. Not realizing rendering (in browsers) happens only between macrotasks, not between every microtask — a runaway microtask chain can also block visual updates, not just other timers.
  4. Debugging "impossible" ordering without accounting for the fact that microtasks scheduled DURING microtask draining still get processed in the same pass.

📋 Quick Reference

text
sync code           → runs first, always, completely
microtasks           → ALL drained next, including newly-added ones, before any macrotask
one macrotask         → runs, may add more microtasks → drain those → repeat
Promise.then          → microtask
setTimeout/setInterval → macrotask

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1Explain the browser event loop.core▾

Run one macrotask (script, event, timer callback), then drain all microtasks (promises, queueMicrotask, MutationObserver), then possibly render (requestAnimationFrame, style, layout, paint), then pick the next macrotask.

Follow-ups interviewers ask
  • Where does requestAnimationFrame run? Before the next paint.
Q2How does Node's event loop differ?core▾

Node uses libuv phases: timers, pending callbacks, poll (I/O), check (setImmediate), close callbacks, with process.nextTick and promise microtasks drained between callbacks. There's no rendering step.

Follow-ups interviewers ask
  • setImmediate vs setTimeout(0)? After I/O, setImmediate runs first.
Q3Can microtasks starve the event loop?core▾

Yes: microtasks that keep queueing microtasks run before any rendering or macrotask, freezing the page or delaying I/O.

Follow-ups interviewers ask
  • Example? A recursive promise chain with no awaits on real I/O.
Q4Why do UI updates sometimes not appear until a loop finishes?senior▾

Rendering happens only between tasks; a long synchronous task (or long microtask chain) prevents painting until it completes.

Follow-ups interviewers ask
  • Fix? Yield back to the event loop between chunks.
Q5Where do fetch callbacks and event handlers run?core▾

As tasks: network completion and user events queue tasks; promise resolutions from fetch run as microtasks within the task that resolves them.

Follow-ups interviewers ask
  • Do user events preempt running code? No, they wait until the stack is empty.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.