Handbooks / Node.js / Chapter 2

The Node.js Event Loop

7 pages · ~5 min✓ Reviewed

Builds on Modules and npm. Next up: Streams and Buffers.

Node.jsPage 1

Single-Threaded Model and Mental Model

1. One Thread for JS

  • JS runs on one main thread: one call stack, one callback at a time
  • So no data races on plain JS variables
  • Single-threaded for JS only: libuv, the OS kernel and a thread pool do I/O and heavy work

2. Mental Model

  1. Stackruns sync code
  2. Backgroundlibuv, kernel, pool
  3. Queuesfinished callbacks
  4. Event looppush one at a time

3. I/O-bound vs CPU-bound

I/O-boundCPU-bound
Examplemany sockets, little CPUheavy computation
FitGoodBad
Whywaiting happens off-threadone slow callback delays every request
Remember

A slow callback makes every other request wait. Node scales on waiting, not on computing.

4. Interview Point

  • Really single-threaded? JS yes; libuv and the thread pool work in the background
  • 10k connections? Sockets wait in the kernel; ready callbacks run one at a time
  • CPU work a problem? One slow callback blocks the stack, so all requests wait
Node.jsPage 2

libuv and the Event Loop Phases

1. What libuv Is

  • libuv = the C library under Node
  • Gives Node the event loop, async network I/O and a thread pool
  • I/O backends: epoll (Linux), kqueue (macOS), IOCP (Windows)

2. Phases of One Loop Turn

  1. timers
  2. pending callbacks
  3. idle / prepare
  4. poll
  5. check
  6. close callbacks
PhaseRuns
timersexpired setTimeout / setInterval
pollnew I/O events and their callbacks
checksetImmediate
closesocket.on('close')

3. The Thread Pool

Uses the poolDoes not
fsTCP sockets
dns.lookupUDP sockets
crypto: pbkdf2, scrypt, randomBytesKernel epoll / kqueue / IOCP
zlib—
  • Default size 4; change with UV_THREADPOOL_SIZE
  • Set it before start-up
Not all async is pooled

Sockets use the OS poller; dns.lookup uses the pool.

Node.jsPage 3

nextTick vs setImmediate vs setTimeout

1. Who Runs Where

APIRuns inWhen
process.nextTicknextTick queueright after current operation
Promise.thenmicrotask queueafter nextTick queue drains
setImmediatecheck phasesame loop turn
setTimeout(fn, 0)timers phaselater turn (0 → 1ms)

2. Order Proof

javascript
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('tick'));
console.log('sync');

CommonJS file

output
sync
tick
promise
timeout
immediate
  • CommonJS: sync → nextTick → promise → timeout/immediate
  • ES module: promise before tick, the module body runs in a microtask
  • Main module: timeout vs immediate is non-deterministic

3. Rules and Traps

  • Inside an I/O callback, setImmediate always fires first
  • Use it to deterministically run after I/O
Starvation

Recursive process.nextTick never lets the loop advance, so I/O starves. Yield with setImmediate instead.

Node.jsPage 4

Blocking the Event Loop

1. What blocking is

  • Blocking = one callback runs long synchronously
  • Timers, requests, I/O all wait: p99 latency spikes for every client

2. Culprits and fixes

CulpritFix
fs.readFileSync, any *Syncasync API
Huge JSON.parse/stringifychunk or worker
ReDoS-prone regexsafer pattern, worker
Big loopschunk, setImmediate
Sync cryptoasync crypto, child process
javascript
function run(a, i = 0) {
  for (let n = 0; n < 500 && i < a.length; n++) work(a[i++]);
  if (i < a.length) setImmediate(run, a, i);
}

yield between chunks

3. Detect it

  • perf_hooks.monitorEventLoopDelay() samples loop lag
  • Sustained high p99 delay = blocked loop
  • --cpu-prof shows the hot function

4. Interview Point

  • Find the blocker? Delay histogram, then --cpu-prof
  • Sync OK at startup? Runs once before serving; in a handler it stalls every client
  • Break a long loop? Chunks, yield with setImmediate
Remember

Offload CPU work to a worker thread or child process.

Node.jsPage 5

Worker Threads for CPU-Bound Work

1. What a Worker Is

  • Separate thread: own V8 isolate, event loop, memory
  • Messages are copied by structured clone
  • Transfer an ArrayBuffer: moved, not copied
  • SharedArrayBuffer + Atomics = shared memory
javascript
// main.js
const w = new Worker('./job.js', { workerData });
w.on('message', handle);
// job.js
parentPort.postMessage(heavy(workerData));

workerData in, postMessage out

2. Thread vs Process

worker_threadschild_processcluster
Unitthread, same processseparate process + IPCforked processes
Weightlightheavyheavy
Use forCPU-bound JSother programsrequests on all cores

3. Pool, Not Per Task

  • Use a pool (e.g. piscina), not a worker per task
  • Spawn per request: startup and memory cost every time
  • No workers for I/O: async I/O is already non-blocking

4. Interview Point

  • Which one? Threads for CPU, cluster for cores, child_process for other programs
  • Sharing data? Clone, transfer, or SharedArrayBuffer with Atomics
  • Worker per request? Startup and memory cost; use a pool
Node.jsPage 6

Node Concurrency Cheat Sheet

1. Loop turn

  • nextTick and promise microtasks run between callbacks
  • One turn visits six phases in order
  1. timers
  2. pending
  3. idle/prepare
  4. pollI/O
  5. checksetImmediate
  6. close

2. Priority

  • sync code
  • process.nextTick
  • promise microtasks
  • setTimeout / setImmediate
  • Proof order holds in CommonJS; in ESM the promise logs first

3. Thread pool

Uses pool (4 threads)No pool
fs, dns.lookupTCP / UDP sockets
crypto, zlibPlain JS code
Set UV_THREADPOOL_SIZE before start-upHandled by epoll/kqueue/IOCP

4. Never in handlers

  • *Sync calls like fs.readFileSync
  • Huge JSON.parse
  • Catastrophic regex
Fix

Chunk work with setImmediate or offload it to worker threads.

Node.jsPage 7

Check yourself

A CommonJS file schedules setTimeout(t, 0), setImmediate(i), Promise.resolve().then(p) and process.nextTick(n), then logs s. What prints, in order?
  • s, then n, then p.
  • t and i come after, in no fixed order from the main module.
Inside an fs.readFile callback you schedule setTimeout(t, 0) and setImmediate(i). Which runs first?
  • i, every time.
  • The callback runs in poll, and check comes next. Timers wait for the next turn.
A handler calls fs.readFileSync on a 200 MB file. One user hits it, yet everyone stalls. Why, and what fixes it?
  • The sync call holds the only JS thread, so no other callback runs.
  • Use a stream or async read. For CPU work, use a worker thread.