The Event Loop
A page requests data from an API that takes two seconds to answer. During those two seconds the buttons still respond, the spinner still spins, and typing in a form still works. Meanwhile the language running all of it executes exactly one statement at a time on exactly one thread. Both of those statements are true, and the second one is the reason the first one is surprising.
The resolution is that JavaScript never waits for slow work to finish. Think of a single barista working a busy counter: she never stands watching the milk steam. She starts the steamer, takes the next ticket, and comes back when the machine beeps. One pair of hands, several machines running on their own, and a rail of tickets waiting to be picked up in order.
One stack, two queues
Four pieces do all the work, and none of them is complicated on its own.
Running code lives on the call stack: calling a function pushes a frame, returning pops it. One stack means one thing at a time, and it means run-to-completion — once a function starts, it runs until it returns, and nothing can interrupt it partway through.
Everything that actually takes time belongs to the host, which is the browser or Node rather than the language: timers, network requests, file reads, user input. None of that runs on the JavaScript thread. When a timer elapses or a response arrives, the host does not interrupt your code; it drops a callback into a queue and leaves it there.
There are two such queues. The task queue holds timer callbacks, event
handlers, and completed I/O. The microtask queue is a second, higher-priority
line holding promise reactions, plus queueMicrotask and MutationObserver
callbacks.
The event loop is the rule that connects them, and it is almost embarrassingly simple: when the stack is empty, take the next callback from a queue and run it. The interesting part is which queue.
Predict first
The scene's script logs a, calls setTimeout(() => log('d'), 0), then Promise.resolve().then(() => log('c')), then logs b. In what order do the four letters print?
Show the answer
The answer is C. The reasoning for each option is now shown under it.
Each chip is one callback waiting · the loop always takes from the left
The script is running — while it holds the stack, nothing else can start.
Microtasks before tasks
Follow the steps in the scene above and the ordering falls out of the rule. While the script is running, the stack is not empty, so nothing from either queue can start — the timer callback and the promise callback both sit and wait even though their work is “ready”. When the script finally returns and the stack empties, the loop does not simply grab the oldest callback. It drains the entire microtask queue first, including microtasks that other microtasks add while it is draining, and only then takes one task.
That is why a promise callback that is already queued always beats a timer, even
one with a zero delay. setTimeout(fn, 0) does not mean “run this now”; it
means “queue this as a task once the timer fires”, and a task cannot start while
microtasks remain.
The zero is optimistic in another way too: timers guarantee a minimum delay
rather than an exact one, and the HTML specification requires browsers to clamp
deeply nested timers to at least four milliseconds.
It also explains a detail that catches people out in async code: whatever
follows an await resumes as a microtask, not as a fresh task. Resumed
continuations therefore run ahead of any timer that was already waiting, in the
order their promises settled.
The same rule has a sharp edge. Because the loop refuses to move on until the
microtask queue is empty, a microtask that keeps queueing another microtask
starves the task queue completely — no rendering, no clicks, no timers, forever.
An infinite while loop is the obvious way to freeze a tab; a promise that
keeps queueing itself is the subtle one, and it looks like ordinary async code.
What actually blocks
Since one thread runs everything, any long synchronous operation — parsing a
huge JSON payload, sorting a large array, a tight loop over a million rows —
holds the stack for exactly as long as it takes, and the page is frozen for that
whole time. async does not help here: awaiting does not create a thread, it
only lets your function return to the loop while the host does work elsewhere.
Genuinely CPU-bound work has to be split into chunks that yield between tasks,
or moved off the thread entirely with a Web Worker.
Rendering comes out of the same budget. The browser paints between tasks, never during one, so a callback that occupies the stack for two hundred milliseconds is two hundred milliseconds in which no frame is produced and no click is handled. The interface is not running slowly; it has stopped. That is why the standard advice is to break long work into pieces that return to the loop rather than to make the long piece marginally faster.
Put a number on that budget. A screen that refreshes 60 times a second gives the browser about 16 milliseconds per frame. One 200 ms callback over a million rows swallows about twelve frames in a row, and a click made as it starts waits the full 200 ms. Run the same loop as fifty 4 ms chunks, each its own task, and the browser gets a chance to paint or handle that click between any two of them. The total work is the same and finishes slightly later, but the page is never stuck for longer than one chunk.
Node runs the same architecture with more structure. Its loop moves through
named phases — timers, pending I/O, poll, check for setImmediate — and it adds
process.nextTick, which runs ahead of promise microtasks (except at the top
level of an ES module, where module loading is itself a promise job and the
promises go first). The details differ;
the rule that governs ordering does not. Microtasks drain before the loop takes
another task.
The classic interview puzzle is worth being able to answer from memory:
type Label = 'a' | 'b' | 'c' | 'd';
function log(label: Label): void {
console.log(label);
}
log('a');
setTimeout(() => log('d'), 0);
Promise.resolve().then(() => log('c'));
log('b');
// Printed: a, b, c, d.
// 'a' and 'b' are synchronous, so they run first, in source order.
// 'c' is a microtask: it runs the moment the stack empties.
// 'd' waits for the next task — a 0 ms timer is still a timer.
Read the source top to bottom and you would expect d before c, since the
timer was registered first. The queue it lands in is what decides, not the order
you wrote.
Try it
Break the long job into chunks
Replace the promise line in the sample with a long synchronous job, and d, a
0 ms timer, has to wait for all of it, just as the click waits in the scene:
function sumRows(rows: number[]): number {
let total = 0;
for (const row of rows) total += row;
return total;
}
const rows = Array.from({ length: 1_000_000 }, (_, index) => index);
log('a');
setTimeout(() => log('d'), 0);
console.log(sumRows(rows));
log('b');
// a, 499999500000, b, dRewrite sumRows so it works through the rows 100,000 at a time and returns to
the event loop between chunks, so that d prints before the total does.
Hint
sumRows has to become async and await something between chunks. Ask which
queue the function resumes from after await Promise.resolve(), and whether a
task like d gets a turn before it. You want a promise that resolves from a
task instead.
Solution
const CHUNK_SIZE = 100_000;
function nextTask(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function sumRows(rows: number[]): Promise<number> {
let total = 0;
for (const [index, row] of rows.entries()) {
total += row;
if ((index + 1) % CHUNK_SIZE === 0) await nextTask();
}
return total;
}nextTask resolves from a timer callback, so each await nextTask() resumes
only after the loop has taken a task, and the timer for d, set before the
first chunk began, is ahead of it in line. The stack is now never held for
longer than one chunk; in a browser, the gaps between chunks are where a frame
gets painted and a click gets handled.
Yielding with await Promise.resolve() looks just as reasonable and does not
help d at all. The function resumes as a microtask, the microtask queue
drains completely before the loop takes a task, and d still prints after the
total.
To see it work, start it the same way:
log('a');
setTimeout(() => log('d'), 0);
sumRows(rows).then((total) => console.log(total));
log('b');
// a, b, d, 499999500000
// (yielding with await Promise.resolve() instead: a, b, 499999500000, d)Check yourself
Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.
Given async function f() { console.log(1); await null; console.log(2); }, what does setTimeout(() => console.log(3), 0); f(); console.log(4); print?
Show the answer
The answer is B. The reasoning for each option is now shown under it.
What does this print? setTimeout(() => console.log(5), 0); Promise.resolve().then(() => console.log(1)).then(() => console.log(2)); Promise.resolve().then(() => console.log(3)).then(() => console.log(4));
Show the answer
The answer is D. The reasoning for each option is now shown under it.
A click handler calls buildReport(), a loop over two million rows that takes 300 ms, and the page freezes on every click. A teammate changes the handler to call setTimeout(buildReport, 0) instead. What happens now?
Show the answer
The answer is A. The reasoning for each option is now shown under it.
A clock should redraw as often as possible, so it is written as function tick() { drawClock(); queueMicrotask(tick); }. After the first tick(), the tab freezes: the clock never visibly changes and clicks do nothing. Why?
Show the answer
The answer is C. The reasoning for each option is now shown under it.
Key takeaways
JavaScript is single-threaded but non-blocking because the host performs slow work off-thread and returns the result as a queued callback.
The event loop only runs a queued callback when the call stack is empty, so a long synchronous function delays everything behind it.
The entire microtask queue drains before the next task runs, which is why an already-queued promise callback beats setTimeout with a zero delay.
Async syntax buys concurrency, not parallelism — CPU-bound work still needs chunking or a worker thread.