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.
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
- Stackruns sync code
- Backgroundlibuv, kernel, pool
- Queuesfinished callbacks
- Event looppush one at a time
3. I/O-bound vs CPU-bound
| I/O-bound | CPU-bound | |
|---|---|---|
| Example | many sockets, little CPU | heavy computation |
| Fit | Good | Bad |
| Why | waiting happens off-thread | one 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
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
- timers
- pending callbacks
- idle / prepare
- poll
- check
- close callbacks
| Phase | Runs |
|---|---|
| timers | expired setTimeout / setInterval |
| poll | new I/O events and their callbacks |
| check | setImmediate |
| close | socket.on('close') |
3. The Thread Pool
| Uses the pool | Does not |
|---|---|
fs | TCP sockets |
dns.lookup | UDP sockets |
crypto: pbkdf2, scrypt, randomBytes | Kernel 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.
nextTick vs setImmediate vs setTimeout
1. Who Runs Where
| API | Runs in | When |
|---|---|---|
process.nextTick | nextTick queue | right after current operation |
Promise.then | microtask queue | after nextTick queue drains |
setImmediate | check phase | same loop turn |
setTimeout(fn, 0) | timers phase | later 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,
setImmediatealways 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.
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
| Culprit | Fix |
|---|---|
fs.readFileSync, any *Sync | async API |
Huge JSON.parse/stringify | chunk or worker |
| ReDoS-prone regex | safer pattern, worker |
| Big loops | chunk, setImmediate |
| Sync crypto | async 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-profshows 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.
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_threads | child_process | cluster | |
|---|---|---|---|
| Unit | thread, same process | separate process + IPC | forked processes |
| Weight | light | heavy | heavy |
| Use for | CPU-bound JS | other programs | requests 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 Concurrency Cheat Sheet
1. Loop turn
nextTickand promise microtasks run between callbacks- One turn visits six phases in order
- timers
- pending
- idle/prepare
- pollI/O
- checksetImmediate
- 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.lookup | TCP / UDP sockets |
crypto, zlib | Plain JS code |
Set UV_THREADPOOL_SIZE before start-up | Handled by epoll/kqueue/IOCP |
4. Never in handlers
*Synccalls likefs.readFileSync- Huge
JSON.parse - Catastrophic regex
Fix
Chunk work with setImmediate or offload it to worker threads.
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, thenn, thenp.tandicome 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.