Skip to content
Chapter 7Lesson 1

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.

To decide what runs next, the runtime uses one stack and two queues, together called the event loop .

Call stack
Top (runs now) ↓
frame
frame
frame
Synchronous frames.
Top entry is what runs now.
Microtask queue
Next →
task
task
task
task
Promise continuations,
await resumptions, queueMicrotask.
Macrotask queue
Next →
task
task
task
setTimeout, I/O,
message events, user input.
The call stack drains synchronous code; between macrotasks the microtask queue is drained completely.

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.

One pass through the loop is a four-step algorithm you can run on paper.

  1. 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.

  2. 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.

  3. (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.

  4. 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.

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

							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');
						
Call stack
Top ↓
empty
empty
empty
empty
f
empty
empty
empty
empty
empty
empty
Microtask queue
Next →
empty
empty
empty
queueMicrotask(log micro)
queueMicrotask(log micro)
queueMicrotask(log micro)
resume f after await
queueMicrotask(log micro)
resume f after await
resume f after await
empty
empty
empty
Macrotask queue
Next →
empty
empty
setTimeout(log macro)
setTimeout(log macro)
setTimeout(log macro)
setTimeout(log macro)
setTimeout(log macro)
setTimeout(log macro)
setTimeout(log macro)
empty
empty
Console
empty
sync 1
sync 1
sync 1
sync 1
sync 2
sync 1
sync 2
sync 1
sync 2
sync 3
sync 1
sync 2
sync 3
micro
sync 1
sync 2
sync 3
micro
micro 2
sync 1
sync 2
sync 3
micro
micro 2
macro
sync 1
sync 2
sync 3
micro
micro 2
macro

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

The 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.

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.

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

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

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

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

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.

The body executes top-to-bottom until it hits an 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.

Even a pre-resolved Promise schedules its continuation as a microtask, never inline. That guarantees the microtask-vs-macrotask ordering: if pre-resolved Promises could skip the queue, the drain order would be undefined.

A setTimeout(fn, 0) cannot run before any pending microtask, because the microtask queue is drained completely between two macrotasks.

The loop runs one macrotask, then drains all microtasks. A 0ms timer is still a macrotask; however fast the runtime fires it, a microtask scheduled before it runs first.

An async function with no await runs synchronously and returns its value directly, without wrapping it in a Promise.

Every 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.

The drain runs until the queue is empty. Microtasks enqueued during the drain join it, and the loop does not move on until every one has run.

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.

The talk below runs about 35 minutes, with the macrotask and microtask split at its core.