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 foundation for every async pattern in the rest of the course.

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

If you had to defend your answer to a colleague, what would you say? The setTimeout delay is 0, so does it fire immediately? The Promise.resolve() is already resolved by the time .then runs, so does its callback skip the queue? Intuition will not settle these questions, but a model of how the runtime works will. By the end of the lesson you should be able to walk anyone through why this is the only order the runtime could produce, with no guessing. Don’t try to solve the puzzle yet. For now, just notice that you can’t, then learn the machinery that closes the gap.

Chapter 7 is about predicting and shaping async execution. This first lesson installs the runtime model that the rest of the chapter builds on, before later lessons cover Promises, async/await patterns, and cancellation. A single thread juggles many pending pieces of work, and the question is always which one runs next. The answer is mechanical, not a matter of intuition.

JavaScript runs on a single thread. To decide what runs next, the runtime uses one stack and two queues, together called the event loop . Each of these three pieces follows its own rules, so each gets its own name.

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. When a function is called, a frame is pushed onto the stack, and when it returns, that frame pops off. Nothing else runs while the stack is non-empty. This is the part of JavaScript you have been working with from the start: the ordinary function-call mechanism.

The microtask queue holds Promise continuations, meaning the callback passed to .then and the code that comes after each await, plus any callback handed to queueMicrotask(fn). Its defining rule is that the queue is drained completely between every two macrotasks: the loop does not move on until the queue is empty.

The macrotask queue (sometimes just called the “task” queue) holds setTimeout and setInterval callbacks, I/O completion callbacks, message events, and user-input handlers. Its defining rule is that the loop runs only one macrotask per iteration.

The whole lesson rests on the asymmetry between these two queues: macrotasks are processed one per loop iteration, while microtasks are processed until the queue is empty. So a microtask scheduled by another microtask still runs in the same drain, but a macrotask scheduled during an iteration has to wait for a later one.

The loop is a four-step algorithm, and you can run it on paper.

  1. Run one macrotask to completion. The initial evaluation of your script counts as 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 one after another. If a microtask schedules another microtask, that new one runs in this same drain, and the queue is fully empty before the loop moves on.

  3. (Browser only) Render, if it is time. The renderer can choose to paint between macrotasks. The exact timing is the browser’s call; for our purposes it is enough to know that rendering lives here, after the microtask drain and before the next macrotask.

  4. Pick the next macrotask. Go back to step 1.

The recipe also explains what goes wrong with a runaway microtask chain. Step 2 says the loop will not advance until the microtask queue is empty, so a chain of microtasks that keeps enqueueing more microtasks blocks rendering, I/O, and user input. Every other task in the system waits behind it. The performance unit later in the course covers how to yield during long work by deliberately scheduling a macrotask between batches. For now, just keep the cost in mind: microtask-heavy code can stop the loop from giving the other queues a turn.

Walking one tick: the canonical interleaving

Section titled “Walking one tick: the canonical interleaving”

A diagram of the queues is fine for learning the vocabulary, but it cannot show cause and effect: which line of code changed which queue, and which queue is drained next. The widget below plays a small program step by step. Each click of “Next” advances one meaningful event-loop step, so you can watch the call stack push and pop, items enter the microtask and macrotask queues, the queues drain by their respective rules, and the console fill up.

The program is short, so read it once and then step through the widget.

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, and the runtime finishes it before draining anything. Second, the microtask drain ran every microtask before the timer: micro and micro 2 both printed before macro. That is the asymmetry from the previous section made concrete, with macrotasks waiting their turn while microtasks empty the queue. Third, the one that catches most newcomers, await Promise.resolve() still scheduled a microtask. The Promise was already resolved by the time await saw it, yet the continuation did not run inline. It went through the queue instead. The next section explains why.

If you read await p as “the function pauses on this line,” you have the wrong model. Here is the one to replace it with:

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 is not stuck on that line. It returns at that line. What came after the await is no longer the next statement to run; it is a callback the runtime will invoke later, off the microtask queue. Three consequences follow from this, and each is a place beginners get confused, so it helps to take them together.

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. At that point greet returns a pending Promise to the caller, and the caller’s next synchronous line (console.log('after call')) keeps running. The line after the await is now a microtask, so it runs only after the caller’s synchronous code finishes.

This is the habit to build: when you write an async function, the part before the first await is not asynchronous at all. It is synchronous code that happens to live inside an async function.

Consequence 2: a pre-resolved Promise does not skip the queue.

This misconception is a common one, so it is worth stating explicitly:

setTimeout(() => console.log('macro'), 0);
Promise.resolve().then(() => console.log('micro'));
console.log('sync');

Output: sync, micro, macro. The Promise was already resolved by the time .then ran, but the callback was still scheduled as a microtask rather than invoked inline. This is what makes the ordering rules reliable. If pre-resolved Promises could run inline, the choice between microtask and macrotask timing would depend on whether the Promise happened to be settled yet, and the drain order would be undefined. The runtime avoids that by always queueing.

This is also why a setTimeout(..., 0) cannot beat an awaited resolved Promise. The timer’s callback is a macrotask, so it has to wait for the microtask drain. No matter how fast the runtime gets at firing 0ms timers, a microtask scheduled before the timer always runs first.

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 value the function returns, but the value the caller receives is a Promise that resolves on the next microtask. There is no fast path where an async function without await skips the Promise wrapping.

queueMicrotask(fn): the explicit microtask scheduler

Section titled “queueMicrotask(fn): the explicit microtask scheduler”

You have already seen two ways to schedule a microtask: Promise.resolve().then(fn) and the code after an await. There is a third, more explicit one. queueMicrotask(fn) schedules fn to run at the next microtask point, after the current synchronous code but before any pending macrotask.

You will rarely reach for this in app code. Where you do see it is in library code that needs to batch work: a state library scheduling subscriber notifications, the React scheduler internally, or an event-bus implementation that wants to coalesce its listeners. It fits when a callback must run after the current synchronous work but before any rendering or I/O, and the author would rather not pay the cost of allocating a settled Promise just to schedule it. So the rule of thumb is to recognize queueMicrotask when you read it in library code, but not to reach for it yourself in app code. App-level async work uses await and the Promise combinators that the next lesson covers.

Everything in this lesson holds in both Node and the browser: the call stack, the two queues, the tick recipe, and await as a microtask scheduler. The microtask and macrotask model is the same everywhere.

Node adds two extras of its own that are worth recognizing by name. process.nextTick(fn) schedules fn on a sub-queue that drains before the microtask queue on each tick. setImmediate(fn) schedules fn on a separate family of macrotasks that fire after the I/O callbacks in a loop iteration. Neither exists in browsers.

Node’s event loop has explicit internal phases (timers, pending callbacks, poll, check, close), but the app code in this course never depends on that phase split. The two-queue model is enough.

Now return to the opening puzzle. You have the model, so you can trace it.

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

Here are three programs of increasing complexity. The point is not to memorize their outputs. It is to run the tick recipe in your head and have it produce the right answer.

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

Six statements to close out. Each one probes a piece of the model: the call stack, the two queues, the tick recipe, or one of the three consequences of await above.

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 function body executes top-to-bottom until it hits an await. Only then does it return a pending Promise to the caller. The code before the await is not asynchronous at all — it just happens to live 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. The continuation is never invoked inline. This is what 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; no matter how fast the runtime is at firing 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 return value the caller receives is 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 microtask queue is empty. Microtasks enqueued during the drain join the same drain. The loop does not move on to the next macrotask until every microtask — including the ones queued mid-drain — 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 has to wait for the microtask drain and one full loop iteration. They differ in priority, not just in syntax.

The talk below is from 2018, but the runtime semantics have not changed and it remains the best single explanation of this model. Watch it after the lesson body to reinforce the moving parts: Jake Archibald demos the queue drain live in a browser. About 35 minutes, with the macrotask and microtask split at its core.