The language and the runtime
The Event Loop, and Why Order Surprises You
The call stack, the task queue and the microtask queue decide the order your logs appear in. Work through a mixed block and stop guessing what runs next.
Two lines of JavaScript that look equally asynchronous run in a different order, and the
difference is invisible in the source. You put a console.log after a
setTimeout(fn, 0) expecting to see the timeout first, and instead the log appears
before it. Nothing is broken. You have a mental model where "asynchronous" means "later" and
no order among the later things, and the runtime has a specific order that it will tell you if
you ask.
This is the lesson to slow down for. Almost every confusing async bug, from a spinner that never clears to a test that passes alone and fails in a suite, comes back to the order described here. It is short enough to memorise and specific enough to check.
All you need is Node 22 or newer and a file. I ran every example on Node 24.11 and pasted the real output, so you can compare yours against it.
The call stack runs synchronous code to completion before anything else
JavaScript has one thread of execution and a call stack. Calling a function pushes a frame and returning pops it, so only the frame on top is running. A timer callback cannot interrupt a function halfway through, and neither can a promise. The current stack drains completely first.
Everything you write at the top level of a file is on that stack when the file starts. When the last top-level statement returns, the stack is empty and the runtime asks what else it has to do.
Two queues, and the microtask queue always goes first
When there is nothing left on the stack, the runtime drains the microtask queue. Then it takes one task from the task queue, runs it, drains the microtask queue again, and repeats. That loop is the event loop, and the two queues have different contents:
| Task queue | Microtask queue | |
|---|---|---|
| Fed by | setTimeout, setInterval, I/O callbacks, UI events | Settled promises, await continuations, queueMicrotask |
| Drained | One item per turn | Completely, after the current stack and after every task |
| Relative order | After all microtasks | Before any task |
| Starvation risk | Low: each task gets a turn | Real: a microtask that queues a microtask keeps the loop in that phase |
setTimeout(fn, 0) is the case that catches everyone. The delay is not a promise
to run at zero milliseconds, it is a request to add fn to the task queue after at
least that long. Zero means "as soon as the current work is finished", and the promise
callbacks that are already in the other queue are part of that work from the loop's point of
view.
A worked example, with the output you will actually see
Predict this before running it. Write the order down, all eight lines, then compare:
console.log("1 script start");
setTimeout(function () { console.log("8 timeout 0"); }, 0);
Promise.resolve().then(function () { console.log("3 then"); });
queueMicrotask(function () { console.log("4 queueMicrotask"); });
Promise.resolve()
.then(function () { console.log("5 first then"); })
.then(function () { console.log("6 chained then"); });
setTimeout(function () { console.log("9 second timeout"); }, 0);
console.log("2 script end");
1 script start
2 script end
3 then
4 queueMicrotask
5 first then
6 chained then
8 timeout 0
9 second timeout
Read the order as four phases of one turn.
- The whole script runs on the stack.
1and2print. Registering the timers and the callbacks does nothing visible yet. - The stack is empty. The microtask queue holds three entries: the two
thencallbacks registered directly, and thequeueMicrotaskcallback, in registration order. They print3,4and5. - Printing
5returns a value, which settles the chained promise and adds the secondthento the same queue, which is still being drained. It prints6in the same turn. - The microtask queue is empty. The loop takes one task, runs it, then drains microtasks
again, then serves the next task:
8and9.
The chained promise is where most predictions go wrong. The second then is not
queued when the first line of the script runs, because the promise it belongs to has not
settled yet. It is queued by the first callback, in the middle of the drain, and it still runs
before any timer because the drain continues until the queue is empty.
The two timers keep their relative order. Timers with the same delay run in the order they were registered, which is the one ordering guarantee the task queue gives you.
Chaining also behaves differently from awaiting, and this one costs people an afternoon
every so often. await value queues the rest of the function as a single microtask.
await somePromise.then(fn) does not, because the promise you are awaiting is the
one then returned, and it does not settle until fn has run and its
result has been unwrapped. That unwrapping is another microtask, so the code after the
await runs a turn later than the code after a plain await on a
resolved value. When a log appears one position later than your model predicts, look for an
extra link in the chain.
Node has a third queue in front
Node also has process.nextTick, whose queue is drained before the promise
microtask queue, so a nextTick callback prints before a
Promise.resolve().then() callback registered first. It is a Node API, not part
of the language, and it does not exist in browsers. Use queueMicrotask when you
want this ordering and the code has to run in both places.
await is a marker for where the function splits in two
An async function runs synchronously until it reaches an await.
At that point it hands back a promise to its caller, registers the rest of its body as a
microtask, and returns control to whoever called it. The synchronous caller keeps going.
When the awaited value is ready and the microtasks are being drained, the rest of the body
resumes.
async function inner() {
console.log("3 inside inner before await");
await null;
console.log("5 after the await in inner");
}
async function outer() {
console.log("2 inside outer before await");
await inner();
console.log("7 after awaiting inner");
}
console.log("1 script start");
outer();
Promise.resolve().then(function () { console.log("6 microtask queued from the script"); });
console.log("4 script end");
1 script start
2 inside outer before await
3 inside inner before await
4 script end
5 after the await in inner
6 microtask queued from the script
7 after awaiting inner
Four things are worth pulling out of that output.
The code before the first await runs immediately, which is why 2
and 3 appear before 4. An async function is not
deferred as a whole; only the part after an await is.
await null still yields. It does not matter that there is no real operation to
wait for. Awaiting a non-promise value wraps it and resumes on the microtask queue, so the
continuation is deferred to the end of the turn like any other microtask.
6 prints before 7, which surprises people the first time. The
callback registered by the script was queued before the outer function resumed, and the promise
returned by inner() only settles when inner finishes, so
outer cannot resume earlier.
An await inside a function is not a yield to the outside world in the way a
thread yield is. It is a yield to the microtask queue, a much shorter and more ordered
trip.
A long synchronous loop delays timers and freezes the page
Because a timer can only run when the stack is empty, a synchronous loop holds everything behind it. This is measurable rather than theoretical. This file starts a zero-delay timer, runs two billion additions, and reports when each thing happened:
const started = Date.now();
setTimeout(function () {
console.log("timeout fired after", Date.now() - started, "ms");
}, 0);
let total = 0;
for (let i = 0; i < 2_000_000_000; i += 1) {
total += i;
}
console.log("loop finished after", Date.now() - started, "ms");
loop finished after 1970 ms
timeout fired after 1973 ms
The timer was ready after zero milliseconds and ran in the same millisecond the loop ended, because the stack was not empty until then. Your milliseconds will differ from mine; a second run of the same file on the same machine gave 1949 and 1952. The ordering is the part that matters.
The same mechanism makes a slow loop look like a frozen interface. The browser runs your code, layout and painting on the main thread, so while a synchronous loop is on the stack, there is no turn in which the page can be repainted and no turn in which a click handler can run. The click is not lost, it is queued, and it is delivered when the loop ends. A handler that blocks for a second makes the page look broken for a second, and no amount of CSS will fix it.
One consequence worth stating, because people assume the opposite: a promise does not get a chance to run while a synchronous loop is running. The microtask queue is only drained when the stack is empty, so a resolved promise scheduled before the loop runs after it.
Turning this into a rule you can use while debugging
When an order does not match what you expected, work through the source in this order. Find
every line that runs synchronously, including the part of an async function before
its first await. Those run first, in source order. Then come the microtasks:
promise callbacks, queueMicrotask calls, and the continuations after
await, in the order they were queued. Finally come the timers and I/O callbacks,
one task per turn, with microtasks drained between each.
One habit makes this faster: give your logs a prefix rather than guessing from the message,
so the output is sortable. console.log("task: cache hit") beats
console.log("hit") when six callbacks are interleaving. And when the order of two
asynchronous operations matters, do not rely on the queue to preserve it. Await the first before
starting the second.
Write the predicted output of this file on paper, all nine lines, and give a one-line reason for the position of each. Only then run it.
console.log("A start");
setTimeout(function () { console.log("B timer"); }, 0);
Promise.resolve().then(function () {
console.log("C then");
return Promise.resolve();
}).then(function () { console.log("D chained then"); });
async function run() {
console.log("E run starts");
await Promise.resolve();
console.log("F after first await");
await Promise.resolve();
console.log("G after second await");
}
run();
queueMicrotask(function () { console.log("H queueMicrotask"); });
console.log("I end");
Then change one line: move the queueMicrotask call to the very top of the
file, above the first console.log. Predict the new order before running it, and
explain which position changed and why.
Show the solution and the reasoning
The unmodified file prints this:
A start
E run starts
I end
C then
F after first await
H queueMicrotask
G after second await
D chained then
B timer
That is not the order I predicted the first time I worked through it, and the two
positions that catch people are F before H, and D
after G. Both have the same cause.
A, E and I are on the stack in source order.
E sits inside an async function but before its first await, so it is
synchronous code like any other. After I, here is the microtask queue, filled in
registration order:
- The first
thencallback, which printsC. - The continuation of
runafter its firstawait, which printsF. - The
queueMicrotaskcallback, which printsH.
Position two is the surprise. run() is called on line 15 of the snippet and
queueMicrotask on the line after it, so the await registers its continuation
first. Awaiting a value that is already resolved queues the rest of the function right there,
as one job. It does not wait for a second turn. That is why F runs before
H even though H looks like the simpler, more immediate call.
Then the drain gets more interesting:
Cprints and returnsPromise.resolve(). The chain has anotherthen, so returning a promise from a callback adds an extra hop. That is what putsDnear the end instead of directly afterC.Fprints, hits the secondawait, and queues the continuation that printsGat the back.Hprints.Gprints.Dprints, at last, because the extra hop only now finished resolving.
Only then is the microtask queue empty and the loop serves the timer for
B. The nine positions come from three rules: the stack first, then the microtask
queue to exhaustion, then one task. Everything inside phase two is decided by which callback
ran first, not by where the line sits in the file.
Moving the queueMicrotask call to the top of the file changes exactly one
position, and it is not D:
A start
E run starts
I end
H queueMicrotask
C then
F after first await
G after second await
D chained then
B timer
H moves to the front of the microtask queue because it was registered before
the others, which is the whole point of the exercise. Everything else keeps its relative
order, including D staying after G. Registering H
earlier cannot pull D forward, because D did not exist in the queue
until C ran and returned a promise. Source order decides only the first
generation of microtasks.
The mistake to avoid in the fix is wrapping the timer in a promise and expecting it to
move up the order. Promise.resolve().then(() => setTimeout(fn, 0)) queues a
microtask that queues a task, so fn still runs after every existing microtask
and after every timer registered earlier. If the order matters, await the earlier operation
instead of arranging the queues.
Progress
Mark this lesson done and it stays marked in this browser.