09 · Concurrency

The Event Loop

7 min read · 5 questions · 1 exercise

JavaScript stays responsive on a single thread by handing slow work to the host and running the callback once the stack is empty.

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?

  1. That reads setTimeout(fn, 0) as run now. Nothing interrupts running code: the timer only queues d as a task, and nothing from any queue starts until the script returns, after b.

  2. That assumes one line, first come, first served. The timer was registered first, but it waits in the task queue, and the loop empties the microtask queue before it takes a task.

  3. a and b print while the script holds the stack. Once it returns, the loop drains the microtask queue, which prints c and runs the extra microtask that c queues, and only then takes one task, which prints d. The click that arrived mid-script is still waiting when the turn ends.

  4. The promise is already resolved, but .then still does not run its callback on the spot. It queues a microtask, and microtasks wait for the stack to empty, so b prints first.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

One turn of the loop

Each chip is one callback waiting · the loop always takes from the left

stackscript
web APIs
microtasks
tasks

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, d

Rewrite 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?

  1. That assumes await null does not pause, since there is nothing to wait for. await always suspends, even on a value that is not a promise, so f hands the stack back and 4 prints before 2.

  2. f runs synchronously up to its await, printing 1, then suspends and returns to the script, which prints 4. The rest of f resumes as a microtask, so 2 prints as soon as the stack empties, and the timer task prints 3 last.

  3. That treats the code after await as a fresh task queued behind the timer. It resumes as a microtask, and the microtask queue drains before the loop takes any task.

  4. Calling an async function does not postpone its body. It starts at once, on the same thread, and runs until its first await, so 1 prints before the script reaches 4.

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

  1. That treats a chain as one unit that runs to the end. Each .then is its own microtask, queued only when the step before it finishes, so it joins the back of the line behind the other chain.

  2. The timer was registered first, but it waits in the task queue, and every microtask runs before the loop takes a task. Registration order only counts within the same queue.

  3. That stops the drain at the microtasks that were waiting when the stack emptied. The loop keeps draining until the queue is empty, including microtasks added along the way, so 2 and 4 beat the timer too.

  4. The microtask queue starts as 1, 3. Running 1 queues 2 behind 3; running 3 queues 4 behind 2. The two chains take turns, and the timer prints 5 once the queue is empty.

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?

  1. The timer only moves the work into a later task on the same thread. When that task runs it holds the stack for the full 300 ms, so no frame is painted and no click is handled until it returns. The freeze has moved, not gone.

  2. That reads a zero delay as now. A timer never runs its callback inside the code that set it; it queues a task, and that task cannot start until the handler has returned.

  3. The host runs the timer, but the callback is JavaScript, and all JavaScript on the page runs on one main thread. Moving work off that thread takes a Web Worker, not a timer.

  4. A callback runs to completion once it starts; the loop cannot pause it halfway to paint a frame. Slices only exist if you write them yourself, as in the exercise above.

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?

  1. Each tick returns before the next one starts, because the next call waits in the queue, not on the stack. The stack never holds more than one tick, so nothing overflows, and no error ever appears.

  2. Each call can be fast and the tab still freezes, because there is never a gap between calls for the loop to paint in. The trouble is not speed; it is that the loop never gets a turn.

  3. The loop drains the microtask queue completely, including microtasks added during the drain, before it paints or takes a task. Every tick adds the next, so the queue never empties. Scheduling with requestAnimationFrame(tick) gives the loop a turn between frames.

  4. Microtasks run on the same single thread as everything else on the page. That is exactly why they can starve it: while one is running, nothing else can.

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.