# How the JavaScript event loop works

> Call stack, microtasks, macrotasks, the render step, and Node's libuv phases — with an interactive step-through visualizer. The interview answer, from first principles.

By Aleksandr Kondaurov · Aug 1, 2026 · 7 min read
Tags: javascript, interviews
URL: https://kondaurov.dev/blog/javascript-event-loop/

Every JavaScript interview eventually arrives at the same question: "what does this print?"

```js
console.log("start")

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

Promise.resolve().then(() => {
  console.log("promise")
})

console.log("end")
```

You can memorize the answer (`start, end, promise, timeout`), but memorized answers fall apart the moment the interviewer adds an `async function` or a `process.nextTick`. What actually holds up is a small mental model of the machine underneath. This post builds that model — and includes an interactive visualizer where you can step through this exact snippet and four trickier ones, watching the stack and the queues move.

<!-- excerpt -->

## One thread and a loop

JavaScript runs your code on a single thread with a single **call stack**. Call a function — a frame is pushed; return — it's popped. The engine can only do one thing at a time: whatever is currently on the stack runs to completion, uninterrupted. Nothing can preempt it — no click handler, no timer, no rendering.

That is the whole explanation for why a busy loop freezes a page:

```js
button.addEventListener("click", () => {
  while (true) {} // the tab is now dead
})
```

The click pushed the handler onto the stack, and the stack never empties again. The browser can't process input, can't paint, can't run your timers — all of that happens _between_ runs of your code, and there is no "between" anymore.

The thing that creates the "between" is the **event loop**. Conceptually it's embarrassingly simple:

```js
while (true) {
  const task = taskQueue.takeOldest()
  run(task) // to completion
  drainMicrotasks()
  maybeRender()
}
```

Everything asynchronous in JavaScript — timers, clicks, network responses — is just a callback waiting in a queue for its turn on this loop. The interesting part, and the part interviews probe, is that there isn't one queue. There are two kinds, with very different priorities.

## Tasks and microtasks

**Tasks** (often called macrotasks) come from the platform: `setTimeout` / `setInterval` callbacks, DOM events, network I/O, `postMessage`. One iteration of the loop takes _one_ task from the queue and runs it.

**Microtasks** come from language-level machinery: promise reactions (`.then` / `.catch` / `.finally`), `queueMicrotask`, `MutationObserver`. They live in a separate queue with a stronger rule:

> Whenever the call stack empties, the engine drains the microtask queue **completely** — including microtasks enqueued while draining — before it takes the next task or renders anything.

That single rule explains the opening snippet. Walk through it:

1. The script itself runs as a task. `start` is logged.
2. `setTimeout(cb, 0)` hands the callback to the browser; it lands in the **task** queue. "0 ms" means "as soon as the loop gets to it", not "now".
3. `Promise.resolve().then(cb)` — the promise is already resolved, so the callback goes straight into the **microtask** queue.
4. `end` is logged, the script finishes, the stack empties.
5. Microtask checkpoint: `promise` is logged.
6. Only then does the loop pick the next task: `timeout`.

So the interview answer "why does a promise beat `setTimeout(0)`" is: _they're in different queues, and the microtask queue is drained after every task, before the next task ever runs._

The "drained completely" part has a sharp edge: a microtask that queues another microtask keeps the checkpoint going. A chain of them can **starve** the task queue indefinitely — timers never fire, rendering never happens, the page is frozen while the stack is technically empty the whole time. `setTimeout(fn, 0)` yields to the browser; `queueMicrotask(fn)` does not.

Step through it yourself — pick a scenario and watch the queues (← / → work too):

<div id="event-loop-demo"></div>

## The render step and requestAnimationFrame

Between tasks, the browser _may_ render: recalculate styles, do layout, paint. May — not must. It typically does so about 60 times per second, when a frame is due, and it can skip rendering entirely if nothing changed or the tab is hidden.

Right before each render comes a third kind of callback: `requestAnimationFrame`. rAF callbacks are not tasks — they're a per-frame list that runs at the start of the render step, after tasks and microtasks, right before paint. That's what makes rAF the correct tool for animation:

- `setTimeout(fn, 16)` drifts and can fire twice between frames or miss one entirely;
- `requestAnimationFrame(fn)` runs exactly once per frame, right before the frame is painted, and pauses in background tabs.

This also answers a subtler interview question: "can I use a microtask to wait for the browser to paint?" No — the microtask checkpoint runs _before_ rendering. If you set `el.style.opacity = 0` and want to animate it to 1, flipping it back in a `.then` happens in the same frame; the browser never paints the intermediate state. You need `requestAnimationFrame` (often two, nested) to get on the other side of a paint.

## async/await is promises with better syntax

`async` / `await` adds no new machinery — it compiles down to the same microtasks. These two functions behave identically:

```js
async function load() {
  const res = await fetch("/api")
  console.log(res.status)
}

function load() {
  return fetch("/api").then((res) => {
    console.log(res.status)
  })
}
```

An `async` function runs **synchronously** until the first `await`. At the `await` it suspends: the function's frame comes off the stack, and the rest of the function is scheduled as a microtask when the awaited promise resolves. Two consequences interviewers like to poke at:

```js
async function main() {
  console.log("1: in main")
  await Promise.resolve()
  console.log("3: after await")
}

main()
console.log("2: after main()")
```

First: the body before `await` runs during the `main()` call itself — no deferral. Second: the code after `await` **never** runs synchronously, even when the promise is already resolved. `await Promise.resolve()` still yields to the microtask queue, so `2` prints before `3`. Mentally rewrite every `await` as "`.then(` rest of the function `)`" and the ordering questions answer themselves. (This is the "await is a .then" scenario in the visualizer above.)

## Node.js: same idea, different loop

Node has no rendering and no DOM events; its event loop (implemented by **libuv**) is organized as a cycle of phases, each with its own callback queue:

- **timers** — expired `setTimeout` / `setInterval` callbacks;
- **pending callbacks** — deferred system-level callbacks;
- **poll** — the heart of the loop: waits for I/O and runs its callbacks (file reads, sockets);
- **check** — `setImmediate` callbacks;
- **close** — `close` events.

The rules you already know still apply — synchronous code first, promise microtasks drained when the stack empties — but Node adds two twists.

### process.nextTick beats everything

`process.nextTick` has its own queue that is **not** the promise microtask queue — and it drains first. After every callback, Node processes the entire nextTick queue, then the promise microtasks, then moves on. Both queues are drained between every phase transition, not once per full loop iteration.

### setTimeout vs setImmediate

The classic trick question. From the main script, the order of `setTimeout(fn, 0)` and `setImmediate(fn)` is **nondeterministic** — it depends on whether a millisecond has ticked over by the time the loop enters the timers phase (a 0 ms timeout is silently clamped to 1 ms). Run it twice, get different answers.

Inside an I/O callback, though, the order is guaranteed:

```js
const fs = require("fs")

fs.readFile(__filename, () => {
  process.nextTick(() => console.log("nextTick"))
  Promise.resolve().then(() => console.log("promise"))
  setTimeout(() => console.log("timeout"), 0)
  setImmediate(() => console.log("immediate"))
  console.log("sync")
})
```

This prints `sync, nextTick, promise, immediate, timeout` — every time. The I/O callback runs in the **poll** phase, and the next phase in the cycle is **check**, where `setImmediate` lives; the timer has to wait for the _next_ loop iteration to reach **timers**. This is the last scenario in the visualizer.

## The cheat sheet

The five answers this model gives you, one line each:

- **Why does the UI freeze?** One thread: while your code is on the call stack, the loop can't process input or render.
- **Why does a promise beat `setTimeout(0)`?** Microtasks are drained completely every time the stack empties, before the next task runs.
- **Can microtasks block the page?** Yes — a self-queueing microtask chain starves tasks _and_ rendering; `setTimeout` yields, `queueMicrotask` doesn't.
- **What does `await` actually do?** Runs synchronously until the first `await`, then suspends and resumes as a microtask — code after `await` is never synchronous.
- **Node ordering?** `process.nextTick` → promise microtasks → the current phase's queue; inside an I/O callback `setImmediate` beats `setTimeout(0)`, from the main script it's a coin flip.

None of this needs memorizing once you hold the model: **one stack, a loop, two queues with different priorities, and rendering squeezed in between tasks.** Everything else is a corollary.
