How the JavaScript event loop works

Aug 1, 2026 · 7 min readjavascriptinterviews

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:

  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):

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 / 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);
  • checksetImmediate callbacks;
  • closeclose 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:

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.