Why a zero-millisecond setTimeout fires after a Promise, why a long loop
freezes an entire server, and how to yield the thread without rewriting everything: the event loop explained with
programs that were run, not with diagrams.
One thread, many waits: where the trick is
Picture a single waiter in a dining room with forty tables. He does not stand in front of the kitchen waiting for the first dish to be ready: he takes the order, carries it to the kitchen, moves on to the next table and comes back when the kitchen rings the bell. JavaScript works the same way. It runs one instruction at a time on its own thread, but it does not spend the long waits (network, disk, timers) on the thread: it hands them to the operating system and resumes when the result is ready.
The mechanism that decides when to resume is called the event loop. In Node.js it is implemented by libuv, the C library that runs asynchronous operations and hands the results back to the V8 engine. The official documentation puts it this way: the event loop lets Node.js perform non-blocking I/O even though JavaScript has only one thread, by offloading operations to the kernel whenever it can. From this follows the rule that holds the whole article together: if your synchronous code occupies the thread, nothing else runs, not even responses that are already ready. For a comparison with the other concurrency models see the first article in the series.
The listings were run with Node.js 24.11.0 on Linux; the output under each one is the output observed. Where the order depends on the machine's load I say so explicitly.
The four parts of the machine
To reason about execution order, four objects are enough. Each item in the list answers a different question: where the code runs, who does the slow work, who brings the result back, and who has priority.
- Call stack: the stack of functions being executed. A call enters it, the return removes it. As long as it is not empty the thread is busy.
- Environment APIs (browser or Node):
setTimeout,fetch,fs.readFile. The real work happens outside the JavaScript thread. - Macrotask queue (in the HTML specifications: task queue): the callbacks of timers, events and completed I/O. One is executed per turn.
- Microtask queue: the continuations of Promises (
.then,await) andqueueMicrotask(). It is drained completely after every macrotask and after the synchronous code.
The criterion that ties the four items together is priority: the stack has absolute precedence, microtasks go before macrotasks, and macrotasks advance one at a time. MDN's guide to microtasks puts it this way: after the oldest task in the queue, microtasks are executed until the queue is empty, and only then can the browser update rendering and move to the next turn. The practical consequence is that an already-resolved Promise always beats a timer, whatever delay you gave it.
The execution order, on the simplest case
The next step is to watch the rule at work in an eight-line program. It mixes synchronous code, a zero-millisecond timer and a chain of two Promises. Before reading the output, try to predict it: most people put the timer before the Promises because "zero milliseconds means right away".
console.log('1 - synchronous');
setTimeout(() => console.log('4 - macrotask'), 0);
Promise.resolve()
.then(() => console.log('2 - microtask'))
.then(() => console.log('3 - microtask, chained'));
console.log('1b - synchronous');
// Output:
// 1 - synchronous
// 1b - synchronous
// 2 - microtask
// 3 - microtask, chained
// 4 - macrotask
The two synchronous lines come out first because the stack must be emptied before anything else. Then it is the turn of the
microtasks: the second .then is queued only when the first has finished, but the engine keeps
draining the queue until it is empty, so line 3 as well goes before the timer. The 0 argument of
setTimeout does not mean "right away": it means "not before this minimum delay", and in any case the
callback enters the macrotask queue, which waits its turn.
The order, then, boils down to four steps. Here the list is numbered because the sequence is the content: swap steps 2 and 3 and the behavior changes.
- Run the synchronous code until the stack is empty.
- Drain the microtask queue, including those queued while you drain it.
- Take the next macrotask and run it.
- Go back to step 2.
The delicate point is "including those queued while you drain it": a microtask can generate others, and the event loop does not move on to the next macrotask until they have all finished. It is the entry point of the anti-pattern you will see shortly.
What await really does
An await does not block the thread: it suspends the function and returns control to whoever
called it. The continuation after the await becomes a microtask. The listing below makes this
visible: main prints "A", schedules a timer, suspends itself, and the synchronous code that follows the
call ("E") goes ahead of its resumption ("C").
async function main() {
console.log('A'); // synchronous: main runs up to the first await
setTimeout(() => console.log('B'), 0); // macrotask
await Promise.resolve('ready'); // main suspends; the continuation is a microtask
console.log('C'); // resumes after the stack has emptied
}
console.log('D');
main();
console.log('E'); // runs before C: main has yielded control
// Output:
// D
// A
// E
// C
// B
Read the output as a story in three acts. Act one, synchronous: "D", then inside main "A", then
the await returns control and "E" arrives. Act two, microtasks: the stack is empty, "C" resumes.
Act three, macrotasks: "B". If you removed the await, "C" would have come out right after "A", and
the whole function would have been one synchronous block.
There is a subtlety the guides forget. People often say async/await is "syntactic
sugar" for .then(), and the sense is right, but the timing does not coincide. In the listing that follows,
a function with two await null and a chain of three .then start together.
// Two microtask chains advancing in alternating steps.
async function conAwait() {
console.log('await: start');
await null;
console.log('await: after the first await');
await null;
console.log('await: after the second await');
}
function conThen() {
return Promise.resolve()
.then(() => console.log('then: first'))
.then(() => console.log('then: second'))
.then(() => console.log('then: third'));
}
conAwait();
conThen();
console.log('synchronous done');
// Output:
// await: start
// synchronous done
// await: after the first await
// then: first
// await: after the second await
// then: second
// then: third
The two chains advance in alternating steps: every await and every .then costs one turn of the
microtask queue, and the engine alternates the two chains on each turn. The concrete advice is never to
build a program's correctness on the fine ordering between two independent chains: if the sequence
matters, link them with an await or a Promise.all. The number of turns an await costs
I observed on Node.js 24.11.0 with a null value; with a thenable object in place of a native
Promise the count is different, and that is why the fine sequence should never be taken for granted.
Microtask starvation: when the queue never empties
If microtasks keep generating others, the rule "drain the queue" becomes a trap: macrotasks,
including timers and network responses, stay waiting. The Node.js documentation describes the same
risk for process.nextTick(): recursive calls starve I/O, because the event loop no longer
reaches the poll phase. To show it without freezing the machine I use a chain limited to 200,000 steps.
// Bounded version of the "starvation": 200000 chained microtasks.
let passi = 0;
const t0 = performance.now();
setTimeout(() => {
const ms = Math.round(performance.now() - t0);
console.log(`timer(0) ran after ${passi} microtasks, about ${ms} ms`);
}, 0);
function catena() {
if (++passi < 200000) Promise.resolve().then(catena);
}
catena();
// Output:
// timer(0) ran after 200000 microtasks, about 9 ms
The timer was registered before the chain, yet it fires after all 200,000 microtasks. The
time (about 9 ms on my machine) is an order of magnitude that changes from one run to another; what does not
change is the order. Remove the if that limits the steps and the timer will never fire, the process
will sit at 100% CPU and no request will be served.
In Node.js the precedence has one more level. The listing below compares the five sources of
deferred work: synchronous code, process.nextTick, Promises, timers and setImmediate.
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('synchronous');
// Output:
// synchronous
// nextTick
// promise
// timeout
// immediate
nextTick goes before the Promise, in a CommonJS module: its queue is drained before
the Promise microtask queue. That is why the Node.js guide recommends setImmediate() "in
all cases", because it is easier to predict, and reserves nextTick for those who need to handle an error or
clean up resources before the event loop continues. The
timeout/immediate pair at the end of the listing also deserves attention: from the main
module their order is not guaranteed, as the next section shows.
The phases of the Node.js event loop
The libuv cycle goes through six phases, always in the same order: timers (expired
setTimeout and setInterval callbacks), pending callbacks (some
deferred system callbacks), idle/prepare (internal use), poll (retrieves
I/O events and runs their callbacks), check (setImmediate callbacks) and
close callbacks (for example socket.on('close')). Between one callback and the
next, Node drains nextTick and microtasks.
A testable consequence follows from this order: setImmediate lives in the check phase, right
after poll. If you are inside an I/O callback (poll phase), the check phase comes before the next
turn of the timers, so immediate always wins.
const fs = require('fs');
fs.readFile(__filename, () => {
// We are in the poll phase: the next one is check (setImmediate), then the timers.
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// Output:
// immediate
// timeout
I ran the program 20 times: immediate came out first 20 times out of 20, which is what the
documentation guarantees for this case. Outside an I/O callback there is no guarantee: the file
main-module.js, with the same two lines at module level, gave immediate first
19 times out of 20 and timeout once. The documentation explains it: from the main module
the order depends on the process's performance, because the timers documentation says a delay below 1 is brought to 1 ms, and the timer may or may not
have already expired when the loop enters the timers phase.
Blocking the event loop: the measurement
A Node server serves all clients from the same thread, so a long synchronous computation delays all the
others. To see it without building a server, I used a zero-millisecond timer as a sentinel: if the
thread is free it fires almost immediately, if it is busy it fires late. The heavy work is an
scrypt derivation (cost parameter N = 216), a cryptographic function that is deliberately
CPU-expensive.
const crypto = require('crypto');
const t0 = performance.now();
setTimeout(() => {
console.log(`timer(0) late by ${Math.round(performance.now() - t0)} ms`);
}, 0);
// Block the thread with synchronous work.
crypto.scryptSync('password', 'salt', 64, { N: 2 ** 16, maxmem: 128 * 1024 * 1024 });
console.log(`scryptSync finished after ${Math.round(performance.now() - t0)} ms`);
// Output:
// scryptSync finished after 157 ms
// timer(0) late by 159 ms
The timer asked for "right away" fired after 159 ms, exactly as long as the synchronous derivation lasted: for that whole time the server would have answered nobody. The value depends on the CPU and the load, and should not be taken as a reference figure; it is the proportion that matters. The same derivation in asynchronous form changes the picture.
const crypto = require('crypto');
const t0 = performance.now();
setTimeout(() => {
console.log(`timer(0) ran after ${Math.round(performance.now() - t0)} ms`);
}, 0);
// Same derivation, but in the libuv threadpool.
crypto.scrypt('password', 'salt', 64, { N: 2 ** 16, maxmem: 128 * 1024 * 1024 }, () => {
console.log(`async scrypt finished after ${Math.round(performance.now() - t0)} ms`);
});
// Output:
// timer(0) ran after 2 ms
// async scrypt finished after 157 ms
Now the timer fires after 2 ms and the derivation lasts as long as before, 157 ms: the work did not become faster, it was moved to the libuv threadpool and the JavaScript thread stayed free. This is the operational meaning of "do not block the event loop": not avoiding the work, but doing it elsewhere. The most frequent occasions are four, and the same pattern applies to all of them (a synchronous version that occupies the thread, an asynchronous version that uses the threadpool or a worker).
- CPU-intensive computations in plain JavaScript: move them to a
Worker(worker_threadsmodule). JSON.parseorJSON.stringifyon very large documents: they are synchronous operations proportional to the size.- The
*Syncvariants offsandcryptoinside server code: use the asynchronous ones. - Regular expressions with catastrophic backtracking on user-controlled input (the Node guide calls them ReDoS).
The items are in order of how often I meet them, not of severity: the fourth is the most insidious because a well-written regex can become a denial-of-service attack with a single malicious input. The common criterion is synchronous duration: any stretch of code that does not yield control for more than a few tens of milliseconds is a candidate to be moved or split.
Yielding the thread without moving the work
You cannot always use a worker: sometimes the work is a loop over in-memory data that has to stay in the same
process. In that case you split it into blocks and, between one block and the next, hand control back to the event loop
with setImmediate. The listing sums a million items in blocks of a hundred thousand and keeps
a 1 ms timer running to check that the thread breathes in the meantime.
// Process 1,000,000 items in blocks of 100,000, yielding the thread between blocks.
const dati = Array.from({ length: 1_000_000 }, (_, i) => i);
let somma = 0;
let tick = 0;
const timer = setInterval(() => { tick++; }, 1);
function elabora(da) {
const fine = Math.min(da + 100_000, dati.length);
for (let i = da; i < fine; i++) somma += dati[i];
if (fine < dati.length) {
setImmediate(() => elabora(fine)); // yields: timers and I/O can run
} else {
clearInterval(timer);
console.log(`sum = ${somma}`);
console.log(`the 1 ms timer fired ${tick > 0 ? 'during' : 'only after'} the processing`);
}
}
elabora(0);
// Output:
// sum = 499999500000
// the 1 ms timer fired during the processing
The sum is exact (0 + 1 + … + 999,999 = 499,999,500,000) and the timer fired during the processing, which
proves the thread did not stay occupied. The price is a little complexity, and every yield
adds a pass through the event loop. A very common variant uses
Promise.resolve().then(...) to yield, and it does not work: microtasks never leave
room for macrotasks, so it would be the same starvation scenario seen above. To really yield you need a
macrotask: setImmediate or setTimeout.
What you can do now
You can predict the order of a program that mixes synchronous code, Promises, await, timers and
setImmediate, and you know why a Promise beats a zero timer. You can recognise microtask
starvation, measure how much a synchronous computation delays the rest of the process with a sentinel timer, and
choose between a worker, an asynchronous version and chunked work.
The next article moves to a model without a shared event loop: goroutines and channels in Go. If your problem is the producer outpacing the consumer, go straight to backpressure and flow control; for the same idea in Python, advanced asyncio.
Sources
- The Node.js Event Loop (nodejs.org) - definition of the event loop, the phases, setImmediate versus setTimeout inside and outside an I/O callback, starvation risk with process.nextTick, accessed on 2026-11-12
- Using microtasks in JavaScript with queueMicrotask() (MDN) - difference between tasks and microtasks, the microtask queue is drained before the next turn, accessed on 2026-11-12
- Don't Block the Event Loop (or the Worker Pool) (nodejs.org) - why a long synchronous computation delays all requests, the ReDoS and JSON DoS cases, task partitioning, accessed on 2026-11-12
- Timers (Node.js API documentation) - a delay below 1 ms is brought to 1 ms, no guarantee on the exact moment or on the order, accessed on 2026-11-12
All the timings in milliseconds and the order counts (19 out of 20, 20 out of 20) come from runs made on a single machine with Node.js 24.11.0; repeat them on yours before using them as a reference.
Commenti
Caricamento commenti...
Sign in to comment