The event loop and the microtask queue
How JavaScript's single-threaded event loop decides what runs next, the model behind every async pattern in the chapter.
Trace this program in your head and write down the order the four letters print:
console.log('a');Promise.resolve().then(() => console.log('b'));setTimeout(() => console.log('c'), 0);queueMicrotask(() => console.log('d'));Could you defend your answer? The setTimeout delay is 0, so does it fire immediately? The Promise.resolve() is already resolved, so does .then skip the queue? Intuition won’t settle these questions; a model of the runtime will. By the end of the lesson you should be able to explain why this is the only order the runtime could produce.
JavaScript runs your code on a single thread juggling many pending pieces of work, and the question is always which piece runs next. The answer is mechanical. This lesson installs the model behind it, the foundation for every async pattern in the chapter.
One stack and two queues
Section titled “One stack and two queues”To decide what runs next, the runtime uses one stack and two queues, together called the event loop .
Top entry is what runs now.
await resumptions, queueMicrotask.
setTimeout, I/O,message events, user input.
The call stack is where synchronous code runs. Calling a function pushes a frame onto the stack; returning pops it off. Nothing else runs while the stack is non-empty. This is the ordinary function-call mechanism.
The microtask queue holds Promise continuations, the callback passed to .then and the code after each await, plus any callback handed to queueMicrotask(fn). It is drained completely between two macrotasks: the loop does not move on until the queue is empty.
The macrotask queue (also called the task queue) holds setTimeout and setInterval callbacks, I/O completion callbacks, message events, and user-input handlers. The loop runs only one macrotask per iteration.
This asymmetry drives the whole lesson. A microtask scheduled by another microtask still runs in the same drain, but a macrotask scheduled during an iteration waits for a later one.
The four steps of one tick
Section titled “The four steps of one tick”One pass through the loop is a four-step algorithm you can run on paper.
-
Run one macrotask to completion. The first evaluation of your script is the first macrotask: its synchronous code pushes and pops frames on the call stack until the stack is empty.
-
Drain the microtask queue. Run every microtask in turn. A microtask that schedules another microtask runs it in this same drain, so the queue is empty before the loop moves on.
-
(Browser only) Render, if it is time. The browser may paint between macrotasks, on its own schedule. What matters is where rendering lives: after the microtask drain, before the next macrotask.
-
Pick the next macrotask. Go back to step 1.
Step 2 is also where a runaway microtask chain bites. The loop will not advance until the queue is empty, so microtasks that keep enqueueing more microtasks block rendering, I/O, and user input until the chain ends. The fix is to yield by scheduling a macrotask between batches.
One tick, step by step
Section titled “One tick, step by step”A static diagram names the queues but can’t show cause and effect: which line changed which queue, and which queue drains next. The widget below steps through a small program. Each click of “Next” advances one event-loop step, so you can watch the call stack push and pop, items enter the queues, the queues drain, and the console fill.
console.log('sync 1');
setTimeout(() => console.log('macro'), 0);
queueMicrotask(() => console.log('micro'));
const f = async () => { console.log('sync 2'); await Promise.resolve(); console.log('micro 2');};f();
console.log('sync 3');
1
console.log('sync 1');
2
3
setTimeout(() => console.log('macro'), 0);
4
5
queueMicrotask(() => console.log('micro'));
6
7
const f = async () => {
8
console.log('sync 2');
9
await Promise.resolve();
10
console.log('micro 2');
11
};
12
f();
13
14
console.log('sync 3');
Step 0 / 10 Top-level script begins running as the first macrotask.
Step 1 / 10 Run console.log('sync 1'). Stack pushes and pops console.log.
Step 2 / 10 setTimeout registers the callback as a macrotask. The body does not run now.
Step 3 / 10 queueMicrotask registers the callback as a microtask.
Step 4 / 10 Call f(). The body runs synchronously up to the first await — sync 2 logs.
Step 5 / 10 f returned from the stack. The code after await is now a queued microtask.
Step 6 / 10 Synchronous tail of the script. The script's macrotask is now finished.
Step 7 / 10 Microtask drain step 1. Run queueMicrotask(log micro). Queue still has the resumption of f.
Step 8 / 10 Microtask drain step 2. The continuation of f runs and logs micro 2. Drain complete.
Step 9 / 10 Microtask queue is empty. The loop picks the next macrotask — the timer callback.
Step 10 / 10 All queues drained. The program has finished.
Notice three things as you step through. First, every synchronous statement ran before any queued callback: sync 1, sync 2, and sync 3 print before micro. The top-level script is itself a macrotask, finished before anything drains. Second, the microtask drain ran every microtask before the timer: micro and micro 2 both print before macro. Third, the one most people miss, await Promise.resolve() still scheduled a microtask. The Promise was already resolved when await saw it, yet the continuation went through the queue instead of running inline. The next section explains why.
await schedules a continuation, it doesn’t block the thread
Section titled “await schedules a continuation, it doesn’t block the thread”If you read await p as “the function pauses on this line,” you have the wrong model. Replace it with this one:
await p does not block the thread. It pauses the surrounding async function and schedules its continuation as a microtask when p settles.
The function does not get stuck on that line; it returns at that line. Everything after the await is no longer the next statement to run, but a callback the runtime invokes later off the microtask queue. Three consequences follow, each a common source of confusion.
Consequence 1: code before the first await runs synchronously on the caller’s stack.
const greet = async () => { console.log('inside, before await'); await Promise.resolve(); console.log('inside, after await');};
console.log('before call');greet();console.log('after call');Output: before call, inside, before await, after call, inside, after await. The body of greet runs top-to-bottom on the caller’s stack until it hits the await. There greet returns a pending Promise, and the caller’s next line (console.log('after call')) keeps running. The line after the await is now a microtask, so it runs only once the caller’s synchronous code finishes.
The habit to build: in an async function, the part before the first await is synchronous code.
Consequence 2: a pre-resolved Promise does not skip the queue.
setTimeout(() => console.log('macro'), 0);Promise.resolve().then(() => console.log('micro'));console.log('sync');Output: sync, micro, macro. The Promise was already resolved when .then ran, yet the callback was still queued as a microtask rather than invoked inline. Always queueing keeps the ordering reliable: if a settled Promise ran inline, timing would depend on whether it happened to be resolved yet. It also means a setTimeout(..., 0) cannot beat an awaited resolved Promise, since the timer’s callback is a macrotask and waits for the microtask drain.
Consequence 3: an async function with no await still returns a Promise.
const f = async () => 42;
const result = f();console.log(result); // Promise { 42 }console.log(await result); // 42The async keyword is what wraps the result in a Promise. The body ran synchronously and 42 is the returned value, but the caller receives a Promise that resolves on the next microtask. No fast path skips the wrapping.
queueMicrotask(fn): the explicit microtask scheduler
Section titled “queueMicrotask(fn): the explicit microtask scheduler”queueMicrotask(fn) schedules fn at the next microtask point, after the current synchronous code but before any pending macrotask. It is a third way to reach the queue, alongside Promise.resolve().then(fn) and the code after an await.
You see it in library code that batches work, such as a state library coalescing subscriber notifications, where the author wants a callback after the current synchronous work without allocating a settled Promise to schedule it. Recognize queueMicrotask when you read it, but reach for await in your own app code.
Node’s extra schedulers
Section titled “Node’s extra schedulers”The model holds unchanged in Node: same call stack, same two queues, same tick recipe, same await. Node adds two schedulers of its own, both absent from browsers. process.nextTick(fn) runs fn on a sub-queue that drains before the microtask queue on each tick. setImmediate(fn) runs fn as a macrotask that fires after the I/O callbacks in a loop iteration.
The puzzle, resolved
Section titled “The puzzle, resolved”Return to the opening puzzle and trace it yourself.
Predict the order of the four letters using the tick recipe. Predict what this program prints, then press Check.
console.log('a');Promise.resolve().then(() => console.log('b'));setTimeout(() => console.log('c'), 0);queueMicrotask(() => console.log('d'));Synchronous run prints a and enqueues work: .then(b) queues a microtask, setTimeout(c, 0) a macrotask, queueMicrotask(d) a second microtask. Since .then ran before the queueMicrotask line, the microtask queue is [b, d]. The script’s macrotask finishes; the runtime drains microtasks FIFO: b, then d. Then one macrotask: c. The 0 on the timer does not race the queue: microtasks drain completely before any macrotask runs.
Practice: three traces
Section titled “Practice: three traces”Run the recipe by hand on three programs of increasing complexity.
Two awaits and a timer. Trace it step by step. Predict what this program prints, then press Check.
const f = async () => { console.log('1'); await Promise.resolve(); console.log('2'); await Promise.resolve(); console.log('3');};
setTimeout(() => console.log('timer'), 0);f();console.log('script end');setTimeout enqueues a macrotask. f() runs synchronously up to the first await: 1 prints, then f returns with console.log('2') and the rest queued as a microtask. The script’s synchronous tail runs: script end. Now the drain: the continuation prints 2, hits the second await, and returns again with console.log('3') queued as another microtask. The drain continues: 3 prints. The queue is empty. Next macrotask: timer.
A microtask schedules another microtask. What does the drain do? Predict what this program prints, then press Check.
queueMicrotask(() => { console.log('outer micro'); queueMicrotask(() => console.log('inner micro'));});setTimeout(() => console.log('timer'), 0);console.log('sync');Synchronous run prints sync. Microtask queue: [outer micro]. Macrotask queue: [timer]. The drain starts. outer micro runs and enqueues inner micro. The drain does not stop; it runs until the queue is empty, so inner micro runs next, in the same drain. Only then does the loop pick the macrotask: timer. The “drain completely” rule made concrete.
A macrotask that schedules a microtask and another macrotask. Each loop iteration is `macrotask → full drain`. Predict what this program prints, then press Check.
setTimeout(() => { console.log('A'); Promise.resolve().then(() => console.log('B')); setTimeout(() => console.log('C'), 0);}, 0);console.log('sync');The script is the first macrotask: sync prints. Macrotask queue: [setTimeout(A)]. Microtask queue empty, drain is a no-op. Next iteration pops the macrotask: A prints, .then(B) enqueues a microtask, setTimeout(C, 0) enqueues a macrotask. The macrotask is done; drain microtasks: B runs. Next iteration pops the macrotask: C. Each loop iteration is macrotask → full microtask drain, so any microtask queued inside a macrotask runs before the next macrotask, even one queued earlier in the same iteration.
Check your understanding
Section titled “Check your understanding”Each statement probes one piece of the model: the call stack, a queue, the tick recipe, or one of the three consequences of await.
Each statement is about the runtime model from this lesson — call stack, microtask queue, macrotask queue, and the tick recipe. Mark each statement True or False.
Code before the first await in an async function runs synchronously on the caller’s stack.
await; only then does it return a pending Promise. The code before the await is not asynchronous, it just lives in an async function.Awaiting a Promise that is already resolved continues execution inline, without yielding to the event loop.
A setTimeout(fn, 0) cannot run before any pending microtask, because the microtask queue is drained completely between two macrotasks.
An async function with no await runs synchronously and returns its value directly, without wrapping it in a Promise.
async function returns a Promise; the async keyword is the wrapping. The body runs synchronously, but the caller receives a Promise that resolves on the next microtask.A microtask that schedules another microtask runs the new one before the next macrotask.
queueMicrotask(fn) and setTimeout(fn, 0) are equivalent ways to defer a callback to the next event-loop iteration.
queueMicrotask enqueues on the microtask queue, which runs before any macrotask. setTimeout(fn, 0) enqueues a macrotask, which waits for the microtask drain and one full loop iteration. They differ in priority, not syntax.Reveal card-by-card review
External resources
Section titled “External resources”The canonical reference. Covers run-to-completion, the queue model, and where rendering fits.
Jake Archibald's written companion to the talk. Worked examples of when each queue drains.
Paste a snippet, step through call stack, microtask and macrotask queues live in the browser.
How V8 reduced await from three microticks to one. Engine-level view of the microtask model.
The talk below runs about 35 minutes, with the macrotask and microtask split at its core.