How the JavaScript event loop works
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.Every JavaScript interview eventually arrives at the same question: “what does this print?”
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.
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:
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:
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:
- The script itself runs as a task.
startis logged. 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”.Promise.resolve().then(cb)— the promise is already resolved, so the callback goes straight into the microtask queue.endis logged, the script finishes, the stack empties.- Microtask checkpoint:
promiseis logged. - 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):
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:
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:
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/setIntervalcallbacks; - pending callbacks — deferred system-level callbacks;
- poll — the heart of the loop: waits for I/O and runs its callbacks (file reads, sockets);
- check —
setImmediatecallbacks; - close —
closeevents.
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:
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;
setTimeoutyields,queueMicrotaskdoesn’t. - What does
awaitactually do? Runs synchronously until the firstawait, then suspends and resumes as a microtask — code afterawaitis never synchronous. - Node ordering?
process.nextTick→ promise microtasks → the current phase’s queue; inside an I/O callbacksetImmediatebeatssetTimeout(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.