lainlog
back

JavaScript Event Loop Explained: Microtasks and the Call Stack

·15 min read

The line that waits its turn

Before we explain anything, try the snippet below. Read it once, then guess the order it prints. Most readers get this wrong on the first try — and the gap between your guess and the truth is exactly what this post fills.

predict the output
predict the output8 logs · 4 queues
Predict the output. Pick the order console.log will print. The snippet mixes sync code, a timer, two Promises, a microtask, and an await. Most readers get this wrong on the first try.

The runtime didn't reorder anything at random. Every letter landed where it did because of one rule about how JavaScript drains its queues — a rule that explains setTimeout(0), makes awaitseamless, and is the reason a single Promise can freeze a tab. The rest of the post traces that snippet, line by line, until the order isn't a guess anymore.

The stack is the gatekeeper#

Every line of running JavaScript lives inside a frame on the call stack — one slot per function call, popped when the call returns. The runtime around it (queues, timers, the event loop) is bigger than the stack and slower than it; none of that machinery can interrupt a frame mid-line. The loop only reaches into its queues when the stack is empty. That's the whole trigger. Not a 16-millisecond tick, not a priority interrupt — just an empty stack.

That's why A, F, and H print before anything else in the snippet above. They're all synchronous calls that ran inside main; the stack stayed busy until console.log("H") returned. Only then does the rest of the runtime get a turn.

If the stack itself feels new — what a frame is, what hoisting does to it, how function calls push and pop — start with the sister post: How JavaScript reads its own future. The two posts work together. This one picks up where that one ends: the moment the stack runs out of frames and the loop gets its turn.

The engine is single-threaded; the runtime is not#

When you call setTimeout, almost nothing about it is JavaScript. The browser hands the timer to a separate, native bit of itself — the same place that handles fetch, click events, IndexedDB, network reads. These are Web APIs: machinery that lives outside the single-threaded engine, runs in parallel, and only pushes a callback back when the work it was given finishes (MDN event loop). Same shape in Node — only the implementation underneath is libuv instead of the browser's native event system (Node docs).

So the engine is single-threaded, but the runtime around it is doing work in parallel. Your setTimeout(B, 0)isn't waiting in line yet when you call it. It's parked in the Web API row. The line forms only when the timer fires and the callback gets handed back. The widget below walks ten ticks of a small program and shows where every piece sits.

runtime · scripted stepper
runtime · scripted steppertick 1 / 10
tick 1 of 10
Tick 0. The script starts. The main frame is on the call stack — every line of the program runs inside it. The two queues and the Web-API row are empty.

By tick 7 the stack is empty, both queues hold one item, and the loop reaches in. It pulls from the microtask queue first. Even though timerB was scheduled before thenC, C prints before B. The microtask jumped the line the rule that let it isn't a quirk; it's the entire model.

One of the two queues always wins#

Two queues, not one. The task queue (sometimes called the macrotask queue) holds work scheduled by setTimeout, setInterval, click handlers, network callbacks, and the like. The microtask queue holds Promise reactions — .then, .catch, .finally — plus queueMicrotask() and MutationObserver callbacks. Different sources, different queues, different priority (HTML §8.1.7).

That's why the snippet's B — a 0 ms setTimeout — comes last, even though it was scheduled second. The task queue can't advance until the microtask queue has been drained completely; C, E, G, and D all clear before B gets its turn.

The priority isn't subtle. After every task — including the initial “run the script” task — the loop performs a microtask checkpoint: drain the microtask queue until it's empty, including any new microtasks added during the drain. Only after that does the loop run one task and check for a render. Three variants below pin three edges of the rule.

predict the output
predict the outputα · baseline
Predict. Tap the ordering you think the runtime will produce. setTimeout vs Promise.then with two synchronous logs.

Variant β is the one that catches most readers: a microtask that schedules another microtask still runs before the next task, because the queue drains completely, not snapshot-at-a-time. Variant γ pins the other half: the order you wrote the lines doesn't decide queue order. Source order schedules; the queues decide what runs.

The single rule, stated plainly: between every task, every render, every idle frame — the microtask queue drains to empty first. Once you carry that sentence, the rest of the post is corollaries.

await is a microtask in disguise#

The rule's biggest payoff is the one most people use without noticing. awaitis sugar. The line after it doesn't run synchronously — even when the value being awaited is already resolved. It runs as a microtask, on the very next checkpoint (MDN await).

Concretely: async function f() { ...; await x; rest(); } desugars to roughly function f() { ...; return Promise.resolve(x).then(() => rest()); }. The body splits at the await and the second half becomes a .then callback. The widget shows both forms stepping in lock-step.

await · the .then in disguise
await · the .then in disguisestep 1 / 4
step 1 of 4
Step 1. Both panes call console.log("1") synchronously inside the function body.

Step 2 is where the equivalence lands. Both panes annotate microtask queue at the same tick because, underneath the syntax, the engine is doing the same thing. Read the right-hand pane first if you came up writing .then chains; read the left-hand pane first if you came up writing async/await. The one rule from §3 covers both.

And it's exactly why the snippet's Gdoesn't land where you'd expect. The await null splits the async IIFE: F runs synchronously, then the line after the await gets parked as a microtask. Gcan't fire until the microtask queue reaches it — which is why it lands after C and E, not next to F.

The trickiest letter in the snippet is D. The first .then callback returns Promise.resolve("D"); adopting that returned promise costs two extra microtask hops before the chained .then((d) => log(d)) finally fires. By the time those hops complete, Ghas already run. That's why the order is C E G D and not C E D G: returning a Promise from a .then isn't free — it's a queue-line you didn't see (V8 blog: faster async functions and promises).

The rule cuts both ways#

The same rule that makes awaitseamless can stall the tab. Rendering — repaints, scroll, the next animation frame — sits downstream of the microtask checkpoint. The loop won't even look at a render opportunity until the microtask queue is empty. And because the queue drains completely, including newly-added microtasks, a chain that schedules itself can hold the checkpoint open indefinitely (web.dev: optimize long tasks).

The widget below makes the priority rule playable. Build a queue: tap + micro a few times, then + macro, then Run — the console writes the firing order so you can see microtasks drain first.

queue race · microtasks vs macrotasks
queue race · microtasks vs macrotaskstick 0 · output 0
Build the queues, then Run. Add a few microtasks and a macrotask. Tap Run: the loop drains every microtask first, then runs one macrotask. Try + self-scheduling micro to feel the priority rule bite.

Each loop tick, the engine drains every pending microtask before it touches macrotasks. Add a few of each and click Run to feel the priority. Now click + self-sched — a microtask whose body schedules another microtask. Every time the engine drains it, the queue grows by one. The microtask queue never empties, so the loop never moves to the macrotask queue. Macrotasks (timers, fetch responses, the next render frame) are stranded. That's microtask starvation:a runaway Promise chain stalls everything else, even though the runtime isn't actually busy — just servicing its own queue.

The line that waits its turn#

The line that waits its turn is the task queue. The line that doesn't is the microtask queue — it gets drained completely, every checkpoint, before anything else moves. One rule explains why await works, why setTimeout(0) is never zero, and why one runaway Promise stalls your whole tab.

Scroll back up and run the opener again. The order isn't a riddle anymore — it's the sequence the runtime had to take.