Why Node Needs an Event Loop

Node runs your JavaScript on one thread. A traditional server spawns a thread per connection, and each thread sits idle while it waits for the database. With 10,000 connections, that is 10,000 mostly-sleeping threads and a great deal of memory.

Node inverts it. There is one thread, it never waits, and the event loop is the mechanism that lets it move on to other work and come back when the data is ready.

Traditional (thread per request)     Node (event loop)
--------------------------------     -----------------
Request 1 -> Thread 1 -> waits...    Request 1 -> start query -> move on
Request 2 -> Thread 2 -> waits...    Request 2 -> start query -> move on
Request 3 -> Thread 3 -> waits...    Request 3 -> start query -> move on
   (3 threads, all idle)                query 2 done -> handle it
                                        query 1 done -> handle it
                                     (1 thread, never idle)

The event loop is not implemented in V8. It lives in libuv, the C library Node uses for asynchronous I/O. V8 runs JavaScript; libuv decides what runs next and when.

The Six Phases

Each turn of the loop — a tick — moves through six phases in a fixed order. Each phase has its own callback queue, and the loop drains that queue (up to a limit) before moving on.

   ┌───────────────────────────┐
┌─>│           timers          │  setTimeout, setInterval callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  some deferred system errors (e.g. TCP ECONNREFUSED)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  internal to Node
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, I/O   │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │  setImmediate callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │  socket.on('close'), etc.
   └───────────────────────────┘
PhaseWhat runs here
timerssetTimeout and setInterval callbacks whose threshold has elapsed
pending callbacksCertain system-level callbacks deferred from the previous tick
idle, prepareNode internals only — you never touch this
pollRetrieves new I/O events and runs their callbacks. This is where the loop waits if there is nothing else to do
checksetImmediate callbacks
close callbacks'close' events on sockets and handles

Poll is the important one. It is where almost all real work happens — incoming HTTP requests, database responses, file reads — and it is where the process blocks when it has nothing to do. If a Node process is genuinely idle, it is parked in poll waiting on the operating system.

The Two Queues That Jump the Line

Two queues sit outside the phase system and are drained between every phase, and after every individual callback:

  1. process.nextTick() queue — highest priority, Node-specific.
  2. Microtask queue — promise callbacks (.then, await continuations, queueMicrotask).

Both are drained completely, and nextTick always goes first.

console.log('1 — sync');

setTimeout(() => console.log('6 — timeout'), 0);
setImmediate(() => console.log('7 — immediate'));

Promise.resolve().then(() => console.log('4 — promise'));
queueMicrotask(() => console.log('5 — microtask'));

process.nextTick(() => console.log('3 — nextTick'));

console.log('2 — sync end');

// Output:
// 1 — sync
// 2 — sync end
// 3 — nextTick      <- nextTick queue drains first
// 4 — promise       <- then the microtask queue, in order queued
// 5 — microtask
// 6 — timeout       <- then the loop's phases begin
// 7 — immediate

Because the nextTick queue is drained completely before the loop advances, a nextTick callback that schedules another nextTick starves the event loop forever. The process pins a CPU core and stops answering requests, with no error and no stack overflow. Recursive setImmediate does not have this problem — it yields to the loop each time.

// Starves the event loop — the server stops responding
function loop() { process.nextTick(loop); }
loop();

// Safe — yields between iterations
function loop() { setImmediate(loop); }
loop();

setTimeout(0) vs setImmediate

The classic Node interview question. At the top level of a script, the order is non-deterministic:

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

// Run this repeatedly — the order genuinely varies

The reason is that setTimeout(fn, 0) is really setTimeout(fn, 1), and whether that millisecond has elapsed by the time the loop first reaches the timers phase depends on how long the process took to start. It is a race with machine load.

Inside an I/O callback, the order is guaranteed. setImmediate always wins:

import { readFile } from 'node:fs';

readFile('package.json', () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

// Always:
// immediate
// timeout

The I/O callback runs in the poll phase. check comes immediately after poll in the same tick, so setImmediate fires right away; the timer has to wait for the next tick to reach timers.

Rule of thumb: setImmediate means “after the current poll phase”. setTimeout(fn, 0) means “on a future tick, roughly now”. When you want to yield without a timer, setImmediate is the honest choice.

The Thread Pool

“Node is single-threaded” is true of your JavaScript and false of the process. libuv keeps a thread pool — four threads by default — for work the operating system cannot do asynchronously.

Uses the thread poolUses the OS event notification (no pool)
fs.* filesystem operationsTCP and HTTP sockets
crypto.pbkdf2, scrypt, randomBytesDNS via dns.resolve
zlib compressionPipes, child process I/O
dns.lookup (yes, really)
# The default is 4. Raise it if you do a lot of fs/crypto/zlib work.
UV_THREADPOOL_SIZE=8 node server.js

Four concurrent bcrypt hashes will saturate the default pool, and the fifth request queues behind them — along with every filesystem read in the process. This is a genuinely common production surprise, because the event loop itself looks perfectly healthy while it happens.

A Full Ordering Example

import { readFile } from 'node:fs/promises';

console.log('A: script start');

setTimeout(() => {
  console.log('E: timeout 0');
  process.nextTick(() => console.log('F: nextTick inside timeout'));
  Promise.resolve().then(() => console.log('G: promise inside timeout'));
}, 0);

setImmediate(() => console.log('H: immediate'));

readFile('package.json', 'utf8').then(() => console.log('I: file read'));

process.nextTick(() => console.log('C: nextTick'));

Promise.resolve().then(() => console.log('D: promise'));

console.log('B: script end');
A: script start
B: script end
C: nextTick                    <- nextTick queue
D: promise                     <- microtask queue
E: timeout 0                   <- timers phase
F: nextTick inside timeout     <- queues drain after EVERY callback
G: promise inside timeout
H: immediate                   <- check phase
I: file read                   <- poll phase, whenever the disk responds

The key insight is line F and G: the nextTick and microtask queues drain after each individual callback, not just between phases. That is why a promise scheduled inside a timer resolves before the loop moves to the check phase.

What Blocks the Loop

Anything synchronous that takes measurable time blocks every concurrent request, because there is one thread and you are holding it.

// Synchronous file I/O — blocks everything
const data = fs.readFileSync('big.json');       // use fs/promises instead

// Synchronous crypto — blocks for hundreds of ms
crypto.pbkdf2Sync(password, salt, 100_000, 64, 'sha512');

// JSON on a large payload — blocks; it is fully synchronous
JSON.parse(twentyMegabyteString);

// A tight loop over a big array
const sorted = millionItems.sort(expensiveComparator);

// A catastrophic regex on attacker-supplied input (ReDoS)
/^(a+)+$/.test(userInput);

The fixes, in order of preference:

  • Use the async version. fs/promises, crypto.pbkdf2 (callback form), zlib async — these hand the work to the thread pool.
  • Chunk it and yield. Process 1,000 items, then await setImmediate(), then continue.
  • Move it off the process. A worker thread, a child process, or a queue consumed by a separate service.
  • Stream it. Do not parse a 20 MB JSON document in one go — stream and parse incrementally.
import { setImmediate as yieldToLoop } from 'node:timers/promises';

async function processLargeArray(items) {
  const results = [];

  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));

    if (i % 1000 === 0) await yieldToLoop();   // let other requests through
  }

  return results;
}

Measuring Event Loop Lag

Event loop lag is the single best health metric for a Node service. It measures how long a callback waits beyond its scheduled time — which is exactly how long a new request would wait.

import { monitorEventLoopDelay } from 'node:perf_hooks';

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

setInterval(() => {
  console.log({
    mean: (histogram.mean / 1e6).toFixed(2) + 'ms',
    p99:  (histogram.percentile(99) / 1e6).toFixed(2) + 'ms',
    max:  (histogram.max / 1e6).toFixed(2) + 'ms',
  });
  histogram.reset();
}, 10_000);
p99 lagVerdict
Under 10 msHealthy
10–50 msBusy — worth investigating
50–200 msUsers are feeling it
Over 200 msSomething is blocking — profile it now

To find what is blocking, take a CPU profile:

node --cpu-prof --cpu-prof-dir=./profiles server.js
# Open the .cpuprofile in Chrome DevTools -> Performance

# Or attach a live debugger
node --inspect server.js

Escaping the Single Thread

When the work is genuinely CPU-bound, no amount of async will help — you need another thread.

// worker.js
import { parentPort, workerData } from 'node:worker_threads';

const result = heavyComputation(workerData);
parentPort.postMessage(result);
// main.js
import { Worker } from 'node:worker_threads';

function runInWorker(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./worker.js', { workerData: data });
    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
    });
  });
}

app.get('/report', async (req, res) => {
  const result = await runInWorker(req.query);   // main thread stays free
  res.json(result);
});

Spawning a worker costs a few milliseconds, so pool them rather than creating one per request (piscina is the usual library). For scaling across CPU cores generally, run one Node process per core with cluster or your process manager, and let the load balancer distribute connections.

Summary

  • Your JavaScript runs on one thread; libuv provides a thread pool for filesystem, crypto and compression work.
  • The loop cycles through six phases; poll is where real I/O lands and where the process idles.
  • process.nextTick drains before microtasks, and both drain after every callback — not just between phases.
  • setTimeout(fn, 0) vs setImmediate is a race at the top level, but setImmediate always wins inside an I/O callback.
  • Anything synchronous and slow blocks every concurrent request. Measure event loop lag; profile when it rises.
  • For real CPU work, use a worker thread or a separate service — async syntax does not create parallelism.