In the previous lesson you discovered that libuv keeps a cycle that tirelessly asks "is anything finished? is there any callback to run?". That cycle is the event loop, and it is the piece that makes Node.js what it is. Everything else — callbacks, promises, events, the HTTP server, the database — is built on top of it.

This is, without exaggeration, the most important lesson in the course. Not because you are going to write code that manipulates the loop directly (you almost never will), but because the order in which things run in Node only makes sense once you know its phases. Without that knowledge, sooner or later you will find yourself staring at console output that appears "in the wrong order", a timer that fires late, or a server that behaves well on your laptop and badly in production, and you will have no mental model to explain it.

By the end you will be able to predict line by line the output of any asynchronous program in Node, you will know when setImmediate beats setTimeout and when it does not, you will understand why process.nextTick cuts in front of everyone and why that can be dangerous, and you will know how to measure the number one health metric of a Node server: event loop lag.

Contents

  1. What the event loop is exactly
  2. The six phases, in order
  3. Phase 1: timers
  4. Phase 2: pending callbacks
  5. Phase 3: idle and prepare
  6. Phase 4: poll, the heart of the heart
  7. Phase 5: check and setImmediate
  8. Phase 6: close callbacks
  9. setTimeout versus setImmediate
  10. The two queues that cut in line: process.nextTick and microtasks
  11. A complete trace you must be able to predict
  12. Loop starvation: the danger of nextTick
  13. setTimeout(fn, 0) is not immediate
  14. Measuring event loop lag
  15. Escena Viva: why a synchronous computation degrades everyone

  1. What the event loop is exactly

The event loop is a while loop written in C inside libuv. Nothing more mystical than that. Its body, heavily simplified, looks like this:

/* Pseudocode of uv_run(), in libuv */
while (has_pending_tasks(loop)) {
  run_timers_phase(loop);
  run_pending_callbacks_phase(loop);
  run_idle_prepare_phase(loop);
  run_poll_phase(loop);              /* this is where it waits */
  run_check_phase(loop);
  run_close_callbacks_phase(loop);
}
/* When the while exits, the process ends */

Each full turn of that while is called a tick or a loop iteration. And the has_pending_tasks condition is exactly the reference count you saw in the previous lesson: as long as there is a scheduled timer, an open socket or an in-flight file read, the loop keeps spinning; when the count reaches zero, the while ends and the process dies.

Three ideas to lock in before we continue:

  1. The event loop lives on the main thread, the same one that runs your JavaScript. They are not two things running in parallel. While your callback runs, the loop is stopped, waiting for you to hand control back. This is the mechanical reason why blocking the main thread blocks everything.
  2. Each phase has its own callback queue. There is no single "event queue"; there are several, and the current phase determines which one gets drained.
  3. Within a phase, callbacks run until their queue is exhausted (with a safety limit in some phases) and only then does it move on to the next.

  1. The six phases, in order

flowchart TD
    START(["Startup:<br/>your whole script runs"]) --> T

    T["<b>1. timers</b><br/>callbacks of expired setTimeout<br/>and setInterval"]
    P["<b>2. pending callbacks</b><br/>I/O callbacks deferred<br/>from the previous iteration"]
    I["<b>3. idle / prepare</b><br/>libuv internal use"]
    POLL["<b>4. poll</b><br/>collects new I/O events<br/>and runs their callbacks.<br/><i>This is where it waits</i>"]
    CH["<b>5. check</b><br/>setImmediate callbacks"]
    CL["<b>6. close callbacks</b><br/>'close' events of sockets,<br/>servers and streams"]
    FIN{"Any references<br/>left?"}

    T --> P --> I --> POLL --> CH --> CL --> FIN
    FIN -->|"Yes"| T
    FIN -->|"No"| END(["The process ends"])

Between each pair of phases — and also between each individual callback, in Node 11 and later — two special queues get drained that are not in the diagram because they are not loop phases: the process.nextTick queue and the promise microtask queue. We will look at them in section 10, and they are the number one source of confusion.

Here is the overview before we go into detail:

# Phase Which callbacks it runs Do you see it often?
1 timers setTimeout and setInterval whose deadline has passed Constantly
2 pending callbacks Deferred callbacks of system operations (e.g. TCP ECONNREFUSED) Rarely on purpose
3 idle, prepare libuv internal use Never from JavaScript
4 poll I/O callbacks: file read, HTTP request received, socket with data This is where a server lives
5 check setImmediate When you explicitly ask for it
6 close callbacks socket.on('close'), server.on('close') When closing resources

  1. Phase 1: timers

In this phase the loop looks at its heap of timers and runs the callbacks of all those whose deadline has already passed.

The essential nuance: setTimeout(fn, 100) does not mean "run fn in exactly 100 ms". It means "do not run fn before 100 ms have passed". The threshold is a minimum, not an appointment.

// src/lab/timers-precision.js
// A timer is a minimum, not a promise of punctuality.

const start = Date.now();

setTimeout(() => {
  console.log(`100 ms timer fired at ${Date.now() - start} ms`);
}, 100);

// A 300 ms synchronous block right after scheduling it.
const deadline = Date.now() + 300;
while (Date.now() < deadline) {
  // Synchronous work: the event loop has not even started spinning.
}

console.log(`Synchronous block finished at ${Date.now() - start} ms`);
Synchronous block finished at 300 ms
100 ms timer fired at 300 ms

The timer had been due since 100 ms, but the loop could not attend to it until the main thread was free. A timer never fires before its deadline, but it can fire a lot later. On a saturated server, that difference is the signal that something is wrong.

A note on setInterval: it does not guarantee an exact cadence. If the callback takes longer than the interval, Node does not pile up pending runs: it drops the ones that did not fit and schedules the next one. A setInterval(fn, 100) whose fn takes 250 ms ends up running every 250 ms, not every 100.

  1. Phase 2: pending callbacks

This phase runs callbacks of certain system operations that were deferred from the previous iteration. The typical case is a TCP error: when you try to connect to a closed port, some systems report the ECONNREFUSED in a way that makes libuv queue it here instead of in poll.

It is the phase you will touch least directly. You should know it exists — because it explains the occasional apparently odd execution order with network errors — but you are not going to schedule anything in it. It is also known in older documentation as pending i/o callbacks.

  1. Phase 3: idle and prepare

libuv internal use, with no public API from JavaScript. Node uses them for its own preparation before entering poll. They appear in every diagram for completeness and so that, when you read the official documentation, you don't wonder what you missed. You can ignore them.

  1. Phase 4: poll, the heart of the heart

If there is one phase you must truly understand, it is this one. A Node server spends 99 % of its life here.

The poll phase does two things:

  1. Work out how long it can afford to block waiting for I/O events.
  2. Process the I/O events that have already arrived, running their callbacks.

And the decision algorithm, which is the interesting part, is this:

flowchart TD
    A["We enter the poll phase"] --> B{"Are there I/O callbacks<br/>in the queue?"}
    B -->|"Yes"| C["Run them until the queue is empty<br/>(or the system limit is reached)"]
    C --> Z["Move on to the check phase"]
    B -->|"No"| D{"Are there setImmediate<br/>callbacks scheduled?"}
    D -->|"Yes"| Z2["End poll NOW<br/>and move on to check"]
    D -->|"No"| E{"Are there timers<br/>about to expire?"}
    E -->|"Yes"| F["BLOCK here waiting for I/O,<br/>at most until the nearest<br/>timer expires"]
    E -->|"No"| G["BLOCK here waiting for I/O<br/>indefinitely"]
    F --> Z
    G --> Z

Four practical consequences of this algorithm:

  • "Blocking" here is what you want, not a problem. When your server has nothing to do, it falls asleep in the operating system's epoll_wait call, consuming no CPU, until a network packet arrives. An idle Node server uses 0 % of the processor. It is the same waiter from the first lesson: they don't stand around staring at the kitchen, they sit down until the bell rings.
  • setImmediate takes priority over waiting. If there is a pending setImmediate, poll does not go to sleep: it cuts short and moves on to check. This is what makes setImmediate reliable inside I/O callbacks.
  • Timers bound the wait. If there is a setTimeout due in 50 ms, poll will sleep at most 50 ms, so it can get back to the timers phase on time.
  • The poll queue has a limit. Node does not process infinitely many I/O events back to back: there is a cap so the other phases are not starved. Whatever is left over is handled in the next iteration.

  1. Phase 5: check and setImmediate

The check phase exists for one single reason: to give setImmediate a guaranteed spot right after the poll phase.

The name setImmediate is misleading: it does not mean "run it now", it means "run it as soon as the I/O of this iteration has been processed". That is: in the check phase of the current iteration.

// src/lab/check.js
const fs = require('node:fs');

fs.readFile(__filename, () => {
  // We are in the POLL phase (this is an I/O callback).
  console.log('1. File read callback (poll phase)');

  setImmediate(() => {
    console.log('2. setImmediate (check phase, same iteration)');
  });

  setTimeout(() => {
    console.log('3. setTimeout 0 (timers phase, next iteration)');
  }, 0);
});
1. File read callback (poll phase)
2. setImmediate (check phase, same iteration)
3. setTimeout 0 (timers phase, next iteration)

This order is always the same, without exception. And you already know why: from poll, the next phase is check, whereas to reach timers you have to complete the whole iteration and start another one.

  1. Phase 6: close callbacks

When a resource is closed — a socket, a server, a stream — its 'close' event is not emitted immediately: it is queued in this last phase.

// src/lab/closing.js
const net = require('node:net');

const server = net.createServer();

server.listen(0, () => {     // Port 0 means "any free one"
  console.log(`1. Server listening on port ${server.address().port}`);

  server.close();            // We ask for the close...
  console.log('2. close() has already returned, but the event has not been emitted yet');

  setImmediate(() => console.log('3. setImmediate (check phase)'));
});

server.on('close', () => {
  console.log('4. close event (close callbacks phase)');
});
1. Server listening on port 43217
2. close() has already returned, but the event has not been emitted yet
3. setImmediate (check phase)
4. close event (close callbacks phase)

Note the underlying lesson, which will repeat itself throughout your career with Node: asking for something to close and something being closed are two different moments. In Module 11 this will matter for shutting down the Escena Viva server gracefully without cutting off in-flight requests.

  1. setTimeout versus setImmediate

Now the classic interview question, whose answer has two parts.

9.1 At the top level of the script: non-deterministic

// src/lab/nondeterministic-order.js

setTimeout(() => console.log('setTimeout 0'), 0);
setImmediate(() => console.log('setImmediate'));

Run this five or six times in a row:

for i in 1 2 3 4 5; do node src/lab/nondeterministic-order.js; echo '---'; done

You will see the order change between runs:

setTimeout 0
setImmediate
---
setImmediate
setTimeout 0
---
setTimeout 0
setImmediate
---

The explanation: setTimeout(fn, 0) is internally turned into a 1 ms deadline. When the loop starts for the first time and enters the timers phase, it checks whether that millisecond has passed. If process startup (loading modules, initializing V8) took more than 1 ms, the timer is already due and runs first; if it took less, it is not due, the loop carries on to check and setImmediate wins. Since that startup time depends on the machine's load, the result is a race.

Practical rule: never write code whose correctness depends on the order between setTimeout and setImmediate at the top level. If the order matters to you, you are using the wrong tool.

9.2 Inside an I/O callback: fully deterministic

As you saw in section 7, inside an I/O callback we are already in the poll phase, and from there check comes immediately after while timers is a whole iteration away. setImmediate always wins. No exceptions, no dependence on the machine.

Context Winner Why
Top level of the script Unpredictable Depends on whether 1 ms has passed since startup
Inside an I/O callback (fs, net, http) setImmediate, always We are in poll; check is the next phase
Inside a setImmediate setTimeout, usually We have already passed check: the next setImmediate goes to the next iteration
Inside a setTimeout Unpredictable The same race as the first case

9.3 When to use each one

I want to… I use
Yield control right now so as not to block, and resume as soon as possible setImmediate(fn)
Run something within N milliseconds setTimeout(fn, N)
Split a long job into pieces that do not block setImmediate between piece and piece
Run something before the loop continues, whatever it takes process.nextTick (carefully, see the next section)

  1. The two queues that cut in line: process.nextTick and microtasks

Here is 80 % of the confusion people suffer with Node. There are two queues that belong to no phase and that are drained between phases, and also between each individual callback:

Queue Filled by Priority
nextTick queue process.nextTick(fn) The highest. It is drained first
microtask queue Promise.then/catch/finally, await, queueMicrotask The second. It is drained after the nextTick one

And the full rule, in the exact order Node acts:

After finishing each callback, and before continuing with the next one or switching phase, Node:

  1. Drains the nextTick queue completely (including any nextTick added while it is draining).
  2. Drains the microtask queue completely (including any added while it is draining).

An important historical note: this behavior changed in Node.js 11. Before that, the queues were drained only between phases, not between individual callbacks. If you find old articles with different output, that is why. Everything in this lesson refers to Node 11 and later, which is all anyone uses today.

A minimal example that demonstrates it:

// src/lab/queues.js

console.log('1. synchronous');

setTimeout(() => console.log('5. setTimeout'), 0);
setImmediate(() => console.log('6. setImmediate'));

Promise.resolve().then(() => console.log('4. promise microtask'));
process.nextTick(() => console.log('3. nextTick'));

console.log('2. synchronous end');
1. synchronous
2. synchronous end
3. nextTick
4. promise microtask
5. setTimeout
6. setImmediate

(The last two lines can swap, for the reason you already know from section 9.1. The first four are immovable.)

And the proof that the queues are drained between each callback, not just between phases:

// src/lab/queues-between-callbacks.js

setTimeout(() => {
  console.log('timer 1');
  process.nextTick(() => console.log('  nextTick from timer 1'));
}, 0);

setTimeout(() => {
  console.log('timer 2');
  process.nextTick(() => console.log('  nextTick from timer 2'));
}, 0);
timer 1
  nextTick from timer 1
timer 2
  nextTick from timer 2

Both timers are in the same phase. If the queues were drained only when switching phase, we would see timer 1, timer 2 and then the two nextTicks. The fact that we don't confirms the rule: they are drained between callback and callback.

When should you use process.nextTick?

Almost never, and for that very reason it is worth knowing the two legitimate cases:

  1. Guaranteeing that a callback is always asynchronous, even on the immediate-error path. We will see it in detail in the next lesson, Callbacks and Asynchronous Programming, because it is its canonical remedy.
  2. Letting your caller register listeners before you emit an event. It is what Node does internally in several places: if a constructor emitted an event synchronously, nobody would have had a chance to subscribe yet.
// Pattern 2: give the caller time to register the listener.
const EventEmitter = require('node:events');

class CatalogLoader extends EventEmitter {
  constructor() {
    super();
    // WRONG: nobody has been able to subscribe yet.
    // this.emit('ready');

    // RIGHT: emitted after the code that created us can call .on()
    process.nextTick(() => this.emit('ready'));
  }
}

const loader = new CatalogLoader();
loader.on('ready', () => console.log('Catalog ready'));   // This does work

When in doubt, use setImmediate instead of nextTick. It is safer, because it respects the loop's cycle instead of skipping it. The reason is in the next section.

  1. A complete trace you must be able to predict

This is the lesson's exam. Read the whole program, write down on paper the output order you expect and only then run it.

// src/lab/trace.js
// Challenge: predict the order of the 8 letters before running it.

console.log('A - synchronous');

setTimeout(() => {
  console.log('B - first setTimeout');
  process.nextTick(() => console.log('C - nextTick inside the first setTimeout'));
  Promise.resolve().then(() => console.log('D - promise inside the first setTimeout'));
}, 0);

setTimeout(() => {
  console.log('E - second setTimeout');
}, 0);

process.nextTick(() => console.log('F - top-level nextTick'));

Promise.resolve().then(() => console.log('G - top-level promise'));

console.log('H - synchronous end');

The answer and the reasoning behind it:

A - synchronous
H - synchronous end
F - top-level nextTick
G - top-level promise
B - first setTimeout
C - nextTick inside the first setTimeout
D - promise inside the first setTimeout
E - second setTimeout

Step by step:

Step What happens Output
1 The script runs top to bottom. The setTimeouts, the nextTick and the .then only enqueue, they do not run A, H
2 The script ends. Before the loop starts spinning, Node drains the nextTick queue F
3 Then the microtask queue G
4 First iteration, timers phase. The first expired callback runs B
5 That callback has finished: the nextTick queue is drained before continuing C
6 And right after it, the microtask one D
7 Only now do we move on to the second timer callback, which was in the same phase E
8 With no queues and no references left, the loop exits and the process ends —

If you got the whole order right, you understand the event loop. If you got C and D wrong by putting them after E, you have the pre-Node 11 model in your head: the queues are drained between callbacks, not just between phases.

  1. Loop starvation: the danger of nextTick

The nextTick queue is drained completely, including any nextTicks queued while it is being drained. That opens the door to a particularly nasty failure:

// src/lab/starvation.js
// WARNING: this program NEVER reaches the setTimeout. Stop it with Ctrl+C.

let rounds = 0;

function recursiveWithNextTick() {
  rounds++;
  if (rounds % 1000000 === 0) {
    console.log(`${rounds} rounds and the loop still has not moved on`);
  }
  process.nextTick(recursiveWithNextTick);   // It re-enqueues itself
}

setTimeout(() => {
  console.log('This is NEVER printed');
}, 100);

recursiveWithNextTick();

The event loop never reaches the timers phase, because before moving on it has to drain the nextTick queue, and that queue refills itself indefinitely. The process burns 100 % of a core, serves nothing and reports no error. This is called event loop starvation, and it is a hard failure to diagnose because the process looks alive.

Now the same structure with setImmediate:

// src/lab/no-starvation.js

let rounds = 0;

function recursiveWithImmediate() {
  rounds++;
  if (rounds >= 1000000) return;
  setImmediate(recursiveWithImmediate);
}

setTimeout(() => {
  console.log(`The setTimeout DOES run, after ${rounds} rounds`);
}, 100);

recursiveWithImmediate();
The setTimeout DOES run, after 41732 rounds

The difference is that a setImmediate queued during the check phase runs in the next iteration, not in the current one. That lets the remaining phases through and the timer gets to fire.

process.nextTick setImmediate
When it runs Before continuing, skipping the loop In the check phase of a loop iteration
Recursion Causes starvation Safe: it yields control every turn
Priority Maximum Normal
Recommended use Very specific cases (see 10) The default option for "later, but soon"

This is the rule you must burn into memory: if you need to defer work, use setImmediate. process.nextTick is a sharp tool for two specific cases.

  1. setTimeout(fn, 0) is not immediate

You have already seen it in passing, but it deserves its own section because it causes real bugs.

When you write setTimeout(fn, 0), Node does not schedule 0 milliseconds. Internally it applies this correction:

// Internal behavior, simplified
if (!(delay >= 1 && delay <= 2147483647)) {
  delay = 1;   // Any value outside the range becomes 1 ms
}

That is: the real minimum is 1 millisecond. And there are two more limits worth knowing:

What you write What actually happens
setTimeout(fn, 0) A 1 ms deadline
setTimeout(fn, -5) A 1 ms deadline
setTimeout(fn) (no delay) A 1 ms deadline
setTimeout(fn, 2147483647) ~24.8 days. The maximum (signed 32-bit integer)
setTimeout(fn, 2147483648) Overflow: it becomes 1 ms and Node warns on the console. It runs immediately!

That last case is a real and recurring bug: somebody schedules "a reminder in 30 days" with setTimeout(fn, 30 * 24 * 60 * 60 * 1000) and the callback fires instantly, because 2,592,000,000 exceeds the maximum. In Escena Viva, a "your event is tomorrow" reminder must never be implemented with a long setTimeout: it is implemented with an external scheduled task or a job queue.

And another practical consequence: a real minimum of 1 ms means that 1000 chained setTimeout(fn, 0) take at least one second. To split work without waiting for anything, setImmediate is the right tool and it is far faster.

  1. Measuring event loop lag

We reach the most applicable part of the lesson. Event loop lag (or delay) is the difference between the moment a callback should have run and the moment it actually ran.

It is the number one health metric of a Node server, above even CPU and memory, and the reason is direct: loop lag is, literally, the time your users are waiting because the process is busy.

14.1 The manual measurement

// src/utils/loop-monitor.js
// Measures event loop lag with a reference timer.

const INTERVAL_MS = 100;

function startMonitoring({ intervalMs = INTERVAL_MS, thresholdMs = 50 } = {}) {
  // hrtime.bigint() gives nanoseconds and is not affected by clock changes.
  let lastMark = process.hrtime.bigint();

  const timer = setInterval(() => {
    const now = process.hrtime.bigint();

    // Real elapsed time, in milliseconds.
    const elapsedMs = Number(now - lastMark) / 1e6;
    lastMark = now;

    // The lag is the excess over the expected interval.
    const lagMs = Math.max(0, elapsedMs - intervalMs);

    if (lagMs > thresholdMs) {
      console.error(`[loop] lag of ${lagMs.toFixed(1)} ms`);
    }
  }, intervalMs);

  // Make sure the monitor does not keep the process alive.
  timer.unref();

  return () => clearInterval(timer);
}

module.exports = { startMonitoring };

This file is, technically, already a module with module.exports. In the CommonJS Modules and require() lesson we will formalize what exactly that line means; for now, accept that it exposes the function so other files can use it.

14.2 The precise measurement: perf_hooks

Node ships a specific and far more accurate tool, because it measures inside libuv instead of with a JavaScript timer:

// src/lab/loop-delay.js
const { monitorEventLoopDelay } = require('node:perf_hooks');

// resolution: how many ms between samples.
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();

// We simulate load: a 200 ms block halfway through the process.
setTimeout(() => {
  const deadline = Date.now() + 200;
  while (Date.now() < deadline) { /* deliberate block */ }
}, 300);

setTimeout(() => {
  histogram.disable();

  // The values come in nanoseconds: we divide by 1e6 to get ms.
  const toMs = (n) => (n / 1e6).toFixed(2);

  console.log('Event loop lag:');
  console.log(`  min          : ${toMs(histogram.min)} ms`);
  console.log(`  mean         : ${toMs(histogram.mean)} ms`);
  console.log(`  max          : ${toMs(histogram.max)} ms`);
  console.log(`  percentile 50: ${toMs(histogram.percentile(50))} ms`);
  console.log(`  percentile 99: ${toMs(histogram.percentile(99))} ms`);
}, 1000);
Event loop lag:
  min          : 9.99 ms
  mean         : 15.42 ms
  max          : 208.67 ms
  percentile 50: 10.21 ms
  percentile 99: 208.67 ms

How to read those numbers:

99th percentile of the lag Diagnosis
< 10 ms Healthy. The process responds with room to spare
10 – 50 ms Acceptable, but something is occupying the thread. Worth investigating
50 – 200 ms Bad. Users notice it on every request
> 200 ms Critical. There is heavy synchronous work. The server is functionally down at times

Notice something important in the example: the mean is 15 ms, a reassuring number. The 99th percentile is 208 ms, a disaster. That is why in production you watch percentiles and not means: the mean hides exactly the problems that matter. In Module 11 we will publish this metric continuously for the Escena Viva server.

  1. Escena Viva: why a synchronous computation degrades everyone

Let's land all of the above in the project. Imagine we add a view to the Escena Viva admin panel with the season's overall occupancy: 3,000 sessions across the year, with their percentage, their revenue and their comparison with the previous season.

The naive implementation is this:

// src/lab/synchronous-occupancy.js
// NAIVE version: it recomputes everything inside the request, synchronously.

function calculateGlobalOccupancy(sessions) {
  const byVenue = new Map();

  for (const session of sessions) {
    // Real work per session: aggregation, formatting, comparisons...
    const key = session.venue;
    const accumulated = byVenue.get(key) ?? { capacity: 0, sold: 0, revenueCents: 0 };

    accumulated.capacity += session.capacity;
    accumulated.sold += session.sold;
    accumulated.revenueCents += session.sold * session.priceCents;

    byVenue.set(key, accumulated);
  }

  return byVenue;
}

If that function takes 300 ms, and an administrator refreshes their panel, this is what happens during those 300 ms:

  • Every ticket purchase request stops. Not slows down: stops.
  • Loop lag rises to 300 ms. The 99th percentile of every user shoots up.
  • If the server handles 200 requests per second, 60 people are left waiting for a panel they are not even looking at.
  • And if the administrator has the panel auto-refreshing every 5 seconds, the server spends 6 % of its life frozen, permanently.

The serious part is that this does not show up in development. On your laptop, with a single user, 300 ms is a page that loads "a bit slowly". In production, with traffic, it is a partial outage that no error log will ever show you, because technically nothing has failed.

The three solutions, in order of simplicity:

Solution What it does Where you'll see it
Chunking with setImmediate Process 200 sessions, yield control, continue. Maximum lag drops to ~20 ms Right here, next section
Compute elsewhere and cache The report is computed every 5 minutes in the background; the request only reads a ready-made value Module 10
Move the computation to another thread or process worker_threads or cluster: the computation happens outside the thread that serves requests Module 10

And here is the chunked version, which you can already write with what you know today:

// src/lab/chunked-occupancy.js
// COOPERATIVE version: it yields control to the loop every BATCH_SIZE sessions.

const BATCH_SIZE = 200;

function calculateGlobalOccupancyChunked(sessions, onDone) {
  const byVenue = new Map();
  let index = 0;

  function processBatch() {
    const end = Math.min(index + BATCH_SIZE, sessions.length);

    for (; index < end; index++) {
      const session = sessions[index];
      const accumulated = byVenue.get(session.venue) ??
        { capacity: 0, sold: 0, revenueCents: 0 };

      accumulated.capacity += session.capacity;
      accumulated.sold += session.sold;
      accumulated.revenueCents += session.sold * session.priceCents;

      byVenue.set(session.venue, accumulated);
    }

    if (index < sessions.length) {
      // We yield control: the loop serves requests before continuing.
      setImmediate(processBatch);
    } else {
      onDone(byVenue);
    }
  }

  processBatch();
}

The total time is practically the same, even slightly worse. But the continuous blocking time goes from 300 ms to about 20 ms, and between batches the server serves purchases normally. It is a deliberate trade: you sacrifice a little report performance in exchange for not penalizing anybody else.

That onDone(byVenue) you see at the end is a callback, and its shape is no accident: it is the pattern that structures all of Node's classic asynchrony and the topic of the next lesson.

Common Mistakes and Tips

Mistake 1: believing setTimeout(fn, 100) runs at exactly 100 ms. It runs at the earliest at 100 ms. If the thread is busy, it is delayed as long as it takes.

Mistake 2: relying on the order between setTimeout(fn, 0) and setImmediate at the top level. It is a race and it changes between runs. Inside an I/O callback it is deterministic.

Mistake 3: using process.nextTick to "defer work a little". It skips the whole loop. Under recursion it causes starvation and the process stops responding without reporting any error. Use setImmediate.

Mistake 4: scheduling long deadlines with setTimeout. Above 2,147,483,647 ms (~24.8 days) it overflows and runs immediately. Escena Viva reminders will go to a job queue, not to a timer.

Mistake 5: watching the mean of the loop lag instead of the 99th percentile. The mean hides the spikes, and the spikes are precisely the problem.

Mistake 6: thinking that chunking with setImmediate solves intensive computation. It makes it tolerable, it does not solve it. If the computation is genuinely heavy, taking it off the thread (Module 10) is the answer.

Mistake 7: believing promises "run on another thread". They don't. A promise settles in the microtask queue, on the very same main thread. await parallelizes nothing on its own.

Tip 1: learn the trace from section 11 by heart. If you can predict those eight letters, you can predict any asynchronous program in Node.

Tip 2: when something "happens in the wrong order", draw the phases. In 95 % of cases the answer is that you were mixing different loop phases.

Tip 3: install lag measurement from day one. It is ten lines of code and it catches problems no other metric shows.

Exercises

Exercise 1: predict the trace

Without running anything, write the exact output order of this program and justify each line, stating which phase or queue it runs in.

const fs = require('node:fs');

console.log('1');

setTimeout(() => {
  console.log('2');
  setImmediate(() => console.log('3'));
  process.nextTick(() => console.log('4'));
}, 0);

setImmediate(() => {
  console.log('5');
  process.nextTick(() => console.log('6'));
});

fs.readFile(__filename, () => {
  console.log('7');
  setTimeout(() => console.log('8'), 0);
  setImmediate(() => console.log('9'));
});

Promise.resolve().then(() => console.log('10'));
process.nextTick(() => console.log('11'));

console.log('12');

Also state which is the only pair of lines whose relative order is not guaranteed.

Exercise 2: detecting the block in Escena Viva

Write src/lab/loop-watchdog.js that:

  1. Uses monitorEventLoopDelay from node:perf_hooks to measure the lag.
  2. Simulates traffic with a 20 ms setInterval that counts served requests.
  3. Runs, at 500 ms, a synchronous computation over 3,000 Escena Viva sessions that takes around 400 ms.
  4. At 2 seconds, prints: served requests versus expected requests, and the full histogram (min, mean, max, p50, p99).
  5. Sets process.exitCode = 1 if the 99th percentile exceeds 50 ms, with a message on stderr explaining the diagnosis.

Exercise 3: chunking the occupancy report

Starting from calculateGlobalOccupancyChunked in section 15, write src/lab/compare-chunking.js that runs both versions (synchronous and chunked) over the same 3,000 sessions while a 20 ms setInterval measures the traffic served, and produces a comparison table with:

  • Total computation time of each version.
  • Maximum continuous block of each version.
  • Requests served during each version.

Then answer in writing: what batch size would you choose for Escena Viva and why? What happens if you set it to 1? And to 3000?

Solutions

Solution 1

1     synchronous
12    synchronous
11    nextTick queue, after the script finishes
10    microtask queue, after the nextTick one
2     timers phase, first iteration
4     nextTick queue, when the timer callback finishes
5     check phase, first iteration
6     nextTick queue, when the setImmediate callback finishes
3     check phase, SECOND iteration (it was queued while already in the first)
7     poll phase (fs.readFile callback)
9     check phase, same iteration as 7
8     timers phase, next iteration

Justification of the stretches people usually get wrong:

  • 11 before 10: the nextTick queue takes priority over the promise microtask queue. Always.
  • 4 right after 2: when the timer callback finishes, the queues are drained before continuing. That is Node 11+ behavior.
  • 3 after 5 and 6: when the timer callback runs (timers phase), scheduling a setImmediate puts it in the check phase of that same iteration… but the top-level setImmediate (5) was already queued before, so it goes first. And 3 was queued during the current iteration while we were already on our way to check: depending on the exact moment it may land in the same check queue or the next one. In practice, 5 always precedes 3.
  • 7 at the end of the group: fs.readFile is real I/O. It takes longer than everything above, so its callback arrives in a later iteration.
  • 9 before 8: inside an I/O callback (poll phase), setImmediate (check, the next phase) always beats setTimeout (timers, the next iteration). This is the deterministic order from section 9.2.

The only non-guaranteed pair in the set is the position of 7 relative to the 2/4/5/6/3 block: it depends on how long the disk takes to return the file. On a machine with the file in cache it may arrive earlier than the trace shows. Everything else is fixed.

Solution 2

// src/lab/loop-watchdog.js
// Measures the impact of a synchronous computation on Escena Viva's traffic.

const { monitorEventLoopDelay } = require('node:perf_hooks');

const TRAFFIC_INTERVAL_MS = 20;
const DURATION_MS = 2000;
const P99_THRESHOLD_MS = 50;

const histogram = monitorEventLoopDelay({ resolution: 5 });
histogram.enable();

const start = Date.now();
let served = 0;

const traffic = setInterval(() => {
  served++;
}, TRAFFIC_INTERVAL_MS);

// Full-season catalog: 3000 sessions.
const sessions = [];
for (let i = 1; i <= 3000; i++) {
  sessions.push({
    id: `ses-${String(Math.ceil(i / 3)).padStart(3, '0')}-${(i % 3) + 1}`,
    venue: ['Teatro Almendra', 'Sala Boveda', 'Auditorio Ribera'][i % 3],
    capacity: 420,
    sold: i % 420,
    priceCents: 2500
  });
}

// Deliberately expensive synchronous computation (~400 ms).
function calculateGlobalOccupancy(sessions) {
  const byVenue = new Map();
  for (const session of sessions) {
    let noise = 0;
    for (let i = 0; i < 60000; i++) {
      noise += Math.sqrt(i);        // Artificial work.
    }
    const accumulated = byVenue.get(session.venue) ??
      { capacity: 0, sold: 0, revenueCents: 0 };
    accumulated.capacity += session.capacity;
    accumulated.sold += session.sold;
    accumulated.revenueCents += session.sold * session.priceCents;
    byVenue.set(session.venue, accumulated);
  }
  return byVenue;
}

setTimeout(() => {
  const t0 = Date.now();
  calculateGlobalOccupancy(sessions);
  console.error(`[report] synchronous computation of ${Date.now() - t0} ms`);
}, 500);

setTimeout(() => {
  clearInterval(traffic);
  histogram.disable();

  const toMs = (n) => (n / 1e6).toFixed(2);
  const expected = Math.floor((Date.now() - start) / TRAFFIC_INTERVAL_MS);
  const p99 = histogram.percentile(99) / 1e6;

  console.log('');
  console.log('TRAFFIC');
  console.log(`  Expected requests : ${expected}`);
  console.log(`  Served requests   : ${served}`);
  console.log(`  Lost              : ${expected - served}`);
  console.log('');
  console.log('EVENT LOOP LAG');
  console.log(`  min  : ${toMs(histogram.min)} ms`);
  console.log(`  mean : ${toMs(histogram.mean)} ms`);
  console.log(`  max  : ${toMs(histogram.max)} ms`);
  console.log(`  p50  : ${toMs(histogram.percentile(50))} ms`);
  console.log(`  p99  : ${toMs(histogram.percentile(99))} ms`);

  if (p99 > P99_THRESHOLD_MS) {
    console.error('');
    console.error(
      `DIAGNOSIS: the 99th percentile (${p99.toFixed(1)} ms) exceeds the threshold of ` +
      `${P99_THRESHOLD_MS} ms. There is synchronous work blocking the main thread. ` +
      'Chunk it with setImmediate, cache the result or move it to a worker.'
    );
    process.exitCode = 1;
  }
}, DURATION_MS);

Typical output:

[report] synchronous computation of 412 ms

TRAFFIC
  Expected requests : 100
  Served requests   : 80
  Lost              : 20

EVENT LOOP LAG
  min  : 4.98 ms
  mean : 12.31 ms
  max  : 414.20 ms
  p50  : 5.12 ms
  p99  : 414.20 ms

DIAGNOSIS: the 99th percentile (414.2 ms) exceeds the threshold of 50 ms. ...

Notice once more the gap between the mean (12 ms, apparently healthy) and the 99th percentile (414 ms, disastrous). It is exactly what you would see on a badly configured production dashboard that only shows means.

Solution 3

// src/lab/compare-chunking.js
// Compares the synchronous computation with the chunked one, measuring traffic served.

const BATCH_SIZE = Number(process.argv[2]) || 200;
const TRAFFIC_INTERVAL_MS = 20;

const sessions = [];
for (let i = 1; i <= 3000; i++) {
  sessions.push({
    venue: ['Teatro Almendra', 'Sala Boveda', 'Auditorio Ribera'][i % 3],
    capacity: 420,
    sold: i % 420,
    priceCents: 2500
  });
}

// Work per session, identical in both versions.
function accumulate(byVenue, session) {
  let noise = 0;
  for (let i = 0; i < 60000; i++) {
    noise += Math.sqrt(i);
  }
  const a = byVenue.get(session.venue) ?? { capacity: 0, sold: 0, revenueCents: 0 };
  a.capacity += session.capacity;
  a.sold += session.sold;
  a.revenueCents += session.sold * session.priceCents;
  byVenue.set(session.venue, a);
}

function version1Synchronous() {
  const byVenue = new Map();
  for (const session of sessions) accumulate(byVenue, session);
  return byVenue;
}

function version2Chunked(onDone) {
  const byVenue = new Map();
  let index = 0;
  let maxBlock = 0;

  function processBatch() {
    const t0 = Date.now();
    const end = Math.min(index + BATCH_SIZE, sessions.length);
    for (; index < end; index++) accumulate(byVenue, sessions[index]);
    maxBlock = Math.max(maxBlock, Date.now() - t0);

    if (index < sessions.length) setImmediate(processBatch);
    else onDone(byVenue, maxBlock);
  }

  processBatch();
}

// --- Measurement ---
let served = 0;
const traffic = setInterval(() => { served++; }, TRAFFIC_INTERVAL_MS);

const results = [];

// Version 1
setTimeout(() => {
  const base = served;
  const t0 = Date.now();
  version1Synchronous();
  const total = Date.now() - t0;
  results.push({ version: 'synchronous', totalMs: total, maxBlockMs: total, served: served - base });

  // Version 2, as soon as the first one finishes.
  const base2 = served;
  const t1 = Date.now();
  version2Chunked((_, maxBlock) => {
    results.push({
      version: `chunked (batch ${BATCH_SIZE})`,
      totalMs: Date.now() - t1,
      maxBlockMs: maxBlock,
      served: served - base2
    });

    clearInterval(traffic);
    console.table(results);
  });
}, 200);

Typical result with a batch of 200:

┌─────────┬───────────────────────┬─────────┬────────────┬────────┐
│ (index) │ version               │ totalMs │ maxBlockMs │ served │
├─────────┼───────────────────────┼─────────┼────────────┼────────┤
│ 0       │ 'synchronous'         │ 408     │ 408        │ 0      │
│ 1       │ 'chunked (batch 200)' │ 431     │ 29         │ 21     │
└─────────┴───────────────────────┴─────────┴────────────┴────────┘

Analysis:

  • The total time gets 6 % worse (408 → 431 ms). That is the price of yielding control fifteen times.
  • The maximum continuous block drops from 408 ms to 29 ms: a fourteenfold improvement in what actually affects users.
  • Requests served go from 0 to 21. In the synchronous version absolutely nothing was served.

A reasoned answer on the batch size:

  • A batch of 1: the maximum block would be tiny (~0.2 ms), but there would be 3,000 turns of the loop. The total time would shoot up because of the cost of scheduling and dispatching 3,000 setImmediates. A lot of administrative work for very little extra gain.
  • A batch of 3000: that is exactly the synchronous version, plus the extra cost of the chunking machinery. The worst of both worlds.
  • The choice for Escena Viva: a batch calibrated so each chunk lasts between 5 and 20 ms. You do not choose by number of items, but by target time, because the cost per session can change. A more robust approach is an adaptive batch: measure how long the previous batch took and adjust the size to get close to 10 ms.

And the honest conclusion: chunking is a band-aid, very useful and applicable today, but the definitive solution for the Escena Viva occupancy panel is not to compute it inside the request — cache the result or move it to a worker, both of which are in Module 10.

Conclusion

You have walked through the heart of Node.js. You now know that the event loop is a while in C inside libuv that spins as long as live references remain, and that each turn goes through six phases in a fixed order: timers for expired setTimeouts, pending callbacks for deferred system callbacks, idle/prepare for internal use, poll — where a server spends its life, waiting for I/O without burning CPU — check for setImmediate, and close callbacks for close events.

You have understood the decisions of the poll phase: it blocks waiting for events, but cuts short immediately if there is a pending setImmediate and bounds its wait if a timer is close. From that comes the full answer to the classic question: setTimeout(fn, 0) versus setImmediate is an unpredictable race at the top level, but inside an I/O callback setImmediate always wins.

You have seen the two queues that are not phases and that cut in line between each callback since Node 11: the process.nextTick one, with the highest priority, and the promise microtask one right behind it. And you have run the eight-letter trace until you could predict it. You also know that recursive nextTick causes loop starvation — a process that burns a whole core and stops responding without a single error — and that the safe option for deferring work is setImmediate. And you know that setTimeout(fn, 0) is really 1 ms, and that above 24.8 days it overflows and runs instantly.

Above all, you have a real diagnostic tool: event loop lag, measured with monitorEventLoopDelay, watched by 99th percentile and not by mean. And you have verified in Escena Viva that a 400 ms synchronous report does not slow down one request: it stops the entire server, leaving dozens of people unserved who only wanted to buy a ticket. Chunking with setImmediate brought the block down from 408 ms to 29 ms; the definitive solution — caching or moving to another thread — waits in Module 10.

All this machinery exists to serve one concrete way of writing code: handing Node a function that will be called when the work is ready. That function has a name and a canonical shape in Node, with the error always in first place, and a set of rules that produce baffling bugs when broken. In the next lesson, Callbacks and Asynchronous Programming, you will write your own asynchronous functions for Escena Viva, discover why try/catch stops working, and build — on purpose — the pyramid of doom that promises will come to tear down.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved