Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 03 / 18 30 MIN

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 queueMicrotask queue
Fed bysetTimeout, setInterval, I/O callbacks, UI eventsSettled promises, await continuations, queueMicrotask
DrainedOne item per turnCompletely, after the current stack and after every task
Relative orderAfter all microtasksBefore any task
Starvation riskLow: each task gets a turnReal: 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.

  1. The whole script runs on the stack. 1 and 2 print. Registering the timers and the callbacks does nothing visible yet.
  2. The stack is empty. The microtask queue holds three entries: the two then callbacks registered directly, and the queueMicrotask callback, in registration order. They print 3, 4 and 5.
  3. Printing 5 returns a value, which settles the chained promise and adds the second then to the same queue, which is still being drained. It prints 6 in the same turn.
  4. The microtask queue is empty. The loop takes one task, runs it, then drains microtasks again, then serves the next task: 8 and 9.

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.

Lab 30 minutes

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:

  1. The first then callback, which prints C.
  2. The continuation of run after its first await, which prints F.
  3. The queueMicrotask callback, which prints H.

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:

  1. C prints and returns Promise.resolve(). The chain has another then, so returning a promise from a callback adds an extra hop. That is what puts D near the end instead of directly after C.
  2. F prints, hits the second await, and queues the continuation that prints G at the back.
  3. H prints.
  4. G prints.
  5. D prints, 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.