Skip to main content

02 - JavaScript Event Loop: How It Really Works, Call Stack, Microtask and Macrotask

Why a Promise beats setTimeout at 0 ms and a long loop freezes Node.js: call stack, microtasks and macrotasks explained with programs that were run.

What do you think about this article?

At a Glance

Why a Promise beats setTimeout at 0 ms and a long loop freezes Node.js: call stack, microtasks and macrotasks explained with programs that were run.

Concurrency 14 min

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.

javascript event loop call stack microtask queue macrotask queue Promise vs setTimeout

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) and queueMicrotask(). 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.

  1. Run the synchronous code until the stack is empty.
  2. Drain the microtask queue, including those queued while you drain it.
  3. Take the next macrotask and run it.
  4. 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_threads module).
  • JSON.parse or JSON.stringify on very large documents: they are synchronous operations proportional to the size.
  • The *Sync variants of fs and crypto inside 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

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.

Previous article ← Concurrency Models

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects