I once spent an afternoon watching a flame graph try to convince me that a 200-line script was running for nine seconds. The script was instant on the engineer's laptop and slow only in production, which is the worst kind of bug. The trace eventually showed the truth: a recursive Promise.resolve().then(...) chain was draining the microtask queue continuously, while six setTimeout(0) callbacks waited their turn behind it. Production was different from the laptop because the laptop's data set was 100 items and production's was 8,000. The laptop never noticed because the queue drained in milliseconds; production noticed because it never finished draining.
The argument I want to make in this piece is that the event loop is one algorithm, written in three steps, repeated forever. The "loop" name suggests something abstract; in reality it is concrete enough to trace by hand. If I can step through a snippet line by line and predict the output, I understand the model. If I cannot, I do not, regardless of how many articles I have read. This piece is the trace-by-hand walkthrough I wish someone had handed me on day one.
The algorithm in three steps
In a single-threaded JavaScript runtime (browser or Node), the event loop runs forever, doing exactly this on every tick:
- Pick the oldest macrotask from the task queue. Run it to completion (the entire synchronous body, including any deeply nested calls).
- Drain the microtask queue. Run every microtask. New microtasks added during draining go to the same queue and get drained before this step exits.
- If we are in a browser and a render is due, run the rendering pipeline (style, layout, paint, composite). Then go back to step 1.
Three steps, in that exact order. Macrotask, microtasks-to-empty, optional render. The repetition is the loop.
Two queues, one optional render. Everything else (timers, I/O, promises, fetch responses, message events) is just a producer for one of those two queues.
What goes in which queue
The two queues do not have arbitrary contents. Each event source produces tasks of one specific kind.
| Source | Queue |
|---|---|
setTimeout, setInterval callbacks | macrotask |
setImmediate (Node) | macrotask (separate phase) |
| I/O completion (file read, socket data) | macrotask |
MessageChannel.postMessage | macrotask |
| Click handlers, keyboard events | macrotask |
requestAnimationFrame callbacks | rendering step (between steps 2 and 3) |
Promise.then callback fired | microtask |
await continuation | microtask |
queueMicrotask(fn) | microtask |
MutationObserver callbacks | microtask |
The rule that catches everyone the first time: microtasks drain to empty between every macrotask. If a microtask schedules another microtask, that new one runs before the next macrotask. If the microtask queue has a hundred items, all hundred run before the next click handler or timer fires. The opening anecdote was a microtask flood crowding out a setTimeout for nine seconds.
A walkthrough that has every queue in it
Here is the snippet I use when I am teaching this. It interleaves macrotasks, microtasks, a requestAnimationFrame callback, and synchronous logging.
Tracing it, in order of execution:
Final output:
Three things are doing the work in that trace. First, all synchronous code runs to completion before any queue is drained. Second, microtasks drain to empty before rendering or the next macrotask. Third, requestAnimationFrame callbacks run in the rendering step, which is between microtask drain and the next macrotask, so they outrun setTimeout even at zero delay (in browsers; Node has no rendering step).
The promise-A-then-B detail is worth pausing on: when .then returns another promise, the chain has to wait for that returned promise to settle, and the resolution requires one extra microtask trip. That is why 5: promise B comes after 8: async after await, even though it was registered earlier.
Where rendering fits
The rendering step is the part most articles skip, and it is where the visible behavior of the page lives. The browser does not render between every macrotask; it renders when the compositor decides a frame is due, which on a 60Hz display is roughly every 16.7 ms. Between renders, dozens of macrotasks and thousands of microtasks may have run.
requestAnimationFrame callbacks run inside the rendering step, just before the layout/paint pipeline. That timing is why animation code is supposed to live in rAF rather than setTimeout(0): the browser guarantees the callback runs once per frame, aligned with the paint, with no risk of running twice in the same frame or being starved by other work.
The other timing trap: a long-running synchronous block freezes the rendering step entirely. The browser cannot render during step 1; it can only render after step 2 has emptied the microtask queue. If your handler runs for 200 ms, no frame ships for 200 ms, regardless of how short your rAF callbacks are. The Long Tasks API is the standard tool for finding these.
Node.js has the same model with extra phases
Node.js implements the event loop on top of libuv, which adds named phases (timers, pending callbacks, idle, poll, check, close callbacks). The conceptual model is the same: pick a macrotask from the right phase, drain microtasks, repeat. The phases let Node distinguish "timer due" from "I/O completion" from "setImmediate callback", but the user-visible ordering rules (microtasks drain between every callback, including between phases in modern Node) match the browser.
Two Node-specific quirks worth knowing: process.nextTick runs in its own queue that drains before microtasks (it predates the spec by years and the maintainers chose not to break compatibility), and setImmediate always runs in the next loop iteration's check phase, so chaining setImmediate inside setImmediate cannot starve I/O the way chaining Promise.resolve().then can starve timers.
How to actually test your understanding
The check I use, on myself and on candidates: write a small snippet with two setTimeouts, two awaits, one queueMicrotask, and one synchronous console.log. Predict the output before running it. If the prediction matches, the model has stuck. If it does not, retrace step by step until you find the queue you put something in the wrong column of.
Output: a d g c e b f. If you got it on the first try, you have internalised the model. If you did not, work through the columns: synchronous (a d g), then microtasks (c e, in registration order), then macrotasks (b f, in registration order). The same approach handles every snippet you will ever see in an interview or in a debugger.
Four corner cases and the answers I have settled on
Is the event loop the same in browsers and Node? The high-level model (macrotask, microtask drain, optional render) is the same. The phases inside the macrotask side differ; Node has named phases (timers, poll, check), browsers have a single task queue. Both drain microtasks between every step.
Does setTimeout(fn, 0) actually run in zero milliseconds?
No. The HTML spec mandates a minimum of about 4 ms after the fifth nested timeout (it varies; spec floor is 4 ms for nested calls). Even before that floor kicks in, the actual delay depends on how busy the macrotask queue is. Treat 0 as "as soon as possible, after the next yield".
Can a microtask suspend the page? Yes. A recursive microtask producer (the opening anecdote) will starve the macrotask side until it stops producing. The browser cannot render and timers cannot fire while microtasks are draining.
When should I use queueMicrotask instead of Promise.resolve().then?
When you want the microtask without a promise object's allocation cost, and when the spec semantics of .then (always resolves with undefined, runs through the promise machinery) do not match what you want. Most user code does not need the difference; library code that schedules thousands of microtasks per second sometimes does.
The loop is a finite-state machine, not a black box
The event loop is a finite-state machine with two queues and an optional render step. Every async surprise reduces to "which queue did this go into, and what was already there ahead of it". The opening nine-second mystery, every "why did my paint freeze" complaint, every interview snippet about ordering, every test that flakes on a tight CI runner, all of it traces back to the same three-step algorithm at the top of this piece.
Trace one snippet by hand, write the prediction down before running it, and check the result against the trace. Do that on five snippets in a row and the loop becomes the kind of thing you reason about without looking, which is the entire return on the time invested.
