Node.js Concurrency Internals
Parent: JavaScript and Node.js · Published reference · snapshot 2026-09-08 · skill lang-js-ts/references/nodejs-concurrency-internals.md
↓ Facts as markdownall context files
How Node.js does concurrency on a single main thread: the libuv event loop that
These notes link each claim to its source. Some citations identify archived research reports that are no longer hosted on this site. A published reference means the content is available; it does not certify independent review or accuracy.Read the editorial policy and follow the sources before relying on a claim.
Node.js Concurrency Internals
- How Node.js does concurrency on a single main thread: the libuv event loop that orchestrates everything, the thread pool that absorbs blocking work, stream backpressure that keeps memory bounded, and the three parallelism models (worker_threads, cluster, child_process) for escaping the single thread. Treat the Node.js API docs and libuv docs as the source of truth for version-sensitive behavior. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#nodejs-concurrency-internals]
- This is the deep-internals companion to references/javascript-nodejs.md (broad language / runtime-API reference). When a question is about ordering, blocking, throughput, or parallelism mechanics, it belongs here. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#nodejs-concurrency-internals]
When to use this reference
- Predicting or explaining event-loop phase ordering (setTimeout vs setImmediate, why I/O callbacks fire where they do) [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-to-use-this-reference]
- process.nextTick vs Promise microtask draining, or diagnosing nextTick starvation [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-to-use-this-reference]
- "Don't block the event loop" - event-loop lag, ReDoS, sync APIs, partitioning vs offloading [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-to-use-this-reference]
- Tuning UV_THREADPOOL_SIZE or diagnosing thread-pool saturation (fs/dns/crypto/zlib) [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-to-use-this-reference]
- Stream backpressure: highWaterMark, write() returning false, 'drain', pipe vs pipeline [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-to-use-this-reference]
- Writing custom Readable/Writable/Transform streams correctly [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-to-use-this-reference]
- Choosing worker_threads vs cluster vs child_process [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-to-use-this-reference]
- SharedArrayBuffer / Atomics / transferList / structured clone between threads [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-to-use-this-reference]
- cluster scheduling (SCHED_RR vs SCHED_NONE), shared ports, worker lifecycle [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-to-use-this-reference]
- child_process spawn/exec/execFile/fork, shell command-injection, maxBuffer, IPC [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-to-use-this-reference]
When NOT to use this reference
- Broad JS/Node language semantics, module systems, everyday API selection → references/javascript-nodejs.md [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-not-to-use-this-reference]
- Debugging tools, breakpoints, heap snapshots, DevTools → references/javascript-node-html-css-debugging-expert.md [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-not-to-use-this-reference]
- Production observability (OpenTelemetry, structured logging) → nodejs-observability / pino-structured-logging (devops-infra hub) [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-not-to-use-this-reference]
- Python asyncio → references/python-patterns.md; Go goroutines/channels → references/go-patterns.md [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#when-not-to-use-this-reference]
1. The libuv event loop and its phases
- Node runs JavaScript on a single main thread. libuv drives an event loop that, on each iteration ("tick" of the loop), passes through six phases in this fixed order, each with its own FIFO callback queue (Node.js Event Loop, libuv design): [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#1-the-libuv-event-loop-and-its-phases]
- After phase 6 the loop wraps back to phase 1. The loop's notion of now is sampled once at the start of an iteration and is not updated again mid-iteration - a timer that becomes due while earlier timers are still running waits until the next iteration. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#1-the-libuv-event-loop-and-its-phases]
The poll phase decides how long the process sleeps
- The poll phase is the heart of the loop (Node.js Event Loop): [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#the-poll-phase-decides-how-long-the-process-sleeps]
- Poll queue not empty → run its callbacks synchronously until the queue drains or a system limit is hit. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#the-poll-phase-decides-how-long-the-process-sleeps]
- Poll queue empty: [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#the-poll-phase-decides-how-long-the-process-sleeps]
- If setImmediate() callbacks are scheduled → end poll, go to check. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#the-poll-phase-decides-how-long-the-process-sleeps]
- Else → block here waiting for I/O, with a computed timeout equal to the nearest pending timer (so timers fire roughly on time). If no timers and no handles keep the loop alive, the process exits. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#the-poll-phase-decides-how-long-the-process-sleeps]
- A process stays alive only while there are active handles or requests (open sockets, listening servers, pending timers, active worker threads). When none remain, the loop ends and Node exits. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#the-poll-phase-decides-how-long-the-process-sleeps]
libuv is what makes I/O async
- For network I/O, libuv uses the OS's native async primitives (epoll on Linux, kqueue on BSD/macOS, IOCP on Windows) - no extra threads. For work the OS cannot do asynchronously (notably file-system I/O, and DNS via getaddrinfo), libuv falls back to the thread pool (Section 3) (libuv design). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#libuv-is-what-makes-io-async]
2. Microtasks: process.nextTick and the Promise queue
- process.nextTick() and the Promise microtask queue are not event-loop phases. They are two separate queues that drain between every callback and between every phase transition - they run before the loop is allowed to advance (Node.js Event Loop). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#2-microtasks-processnexttick-and-the-promise-queue]
- Drain order at each checkpoint: [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#2-microtasks-processnexttick-and-the-promise-queue]
- The entire process.nextTick queue (highest priority), then [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#2-microtasks-processnexttick-and-the-promise-queue]
- The entire Promise microtask queue (.then / await continuations, queueMicrotask). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#2-microtasks-processnexttick-and-the-promise-queue]
- Both are fully drained before the next phase callback runs. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#2-microtasks-processnexttick-and-the-promise-queue]
nextTick / microtask starvation
- Because these queues drain completely before the loop advances, recursively scheduling process.nextTick() (or microtasks) starves the loop - I/O, timers, and setImmediate never run: [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#nexttick-microtask-starvation]
- Prefer setImmediate() when you want to yield back to the loop. Legitimate nextTick uses: defer a callback so the caller's synchronous code finishes first, emit an event after a constructor returns (so listeners can attach), or normalize an API to "always async." [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#nexttick-microtask-starvation]
setTimeout(…, 0) vs setImmediate
- In the main module / top level: order is non-deterministic - it depends on how fast the process reaches the timers phase vs whether the 0-ms timer's threshold has elapsed. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#settimeout-0-vs-setimmediate]
- Inside an I/O callback (poll phase): setImmediate always fires before setTimeout(…, 0), because the loop goes poll → check next, and only reaches timers on the following iteration. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#settimeout-0-vs-setimmediate]
3. The libuv thread pool
- A global thread pool, shared across all event loops in the process, runs work that has no async OS primitive (libuv threadpool): [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#3-the-libuv-thread-pool]
- Default size: 4 threads. Configurable via the UV_THREADPOOL_SIZE environment variable, max 1024 (raised from 128 in libuv 1.30.0). Must be set before the pool is first used (effectively at process start); libuv preallocates the threads on first use. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#3-the-libuv-thread-pool]
- What uses it: fs.* file operations, dns.lookup() (getaddrinfo/getnameinfo), crypto (pbkdf2, randomBytes, scrypt), and zlib compression. Network sockets do NOT - they use the OS event mechanism, not the pool. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#3-the-libuv-thread-pool]
- Saturation symptom: with the default 4 threads, 5+ concurrent fs/crypto/zlib/DNS operations queue; the 5th waits for a free thread even though the CPU is idle. Latency climbs with no obvious CPU cause. Raise UV_THREADPOOL_SIZE (a common starting point is the number of logical cores, or higher for I/O-heavy workloads) and measure. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#3-the-libuv-thread-pool]
- > Pitfall: dns.lookup() uses the pool; the lower-level dns.resolve*() family uses the > network and does not. A burst of dns.lookup() (which most connection code calls > implicitly) can starve the pool. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#3-the-libuv-thread-pool]
4. Don't block the event loop (or the pool)
- Node serves many clients with few threads, so any synchronous CPU work on the main thread stalls every other client - a throughput problem and a DoS vector (Don't Block the Event Loop). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#4-dont-block-the-event-loop-or-the-pool]
- Things that block the main thread: [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#4-dont-block-the-event-loop-or-the-pool]
- Synchronous APIs in request paths: fs.readFileSync, crypto.pbkdf2Sync, zlib.*Sync, child_process.execSync, JSON.parse/JSON.stringify on large payloads. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#4-dont-block-the-event-loop-or-the-pool]
- ReDoS - catastrophic backtracking from nested quantifiers (/(\/.+)+$/), overlapping alternation (/(a|a)*/), or backreferences; an attacker triggers exponential time. Mitigate with indexOf, safe-regex, or node-re2 (linear-time engine), and bound input size. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#4-dont-block-the-event-loop-or-the-pool]
- Long synchronous loops / O(n²) work per request. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#4-dont-block-the-event-loop-or-the-pool]
- Partitioning (keep work on the loop but yield): break the loop into chunks and reschedule each chunk with setImmediate() so other callbacks interleave. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#4-dont-block-the-event-loop-or-the-pool]
- Offloading (move work off the loop): worker_threads for CPU-bound JS, child_process for separate programs. Use a pool of workers - never spawn one per request (fork-bomb / unbounded memory). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#4-dont-block-the-event-loop-or-the-pool]
- Measure event-loop lag with perf_hooks.monitorEventLoopDelay() (histogram) or performance.eventLoopUtilization() (ELU). Don't block the pool either: one slow thread-pool task (e.g. reading /dev/random) ties up 1 of 4 threads; partition large reads or use streams (auto-partitioned). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#4-dont-block-the-event-loop-or-the-pool]
5. Streams and backpressure
- A stream moves data in chunks instead of buffering it all in memory. Backpressure is the flow-control signal that stops a fast producer from outrunning a slow consumer; ignoring it lets internal buffers grow without bound (Backpressuring in Streams, Node.js Stream API). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#5-streams-and-backpressure]
- Four stream types: Readable (source), Writable (sink), Duplex (both, independent sides, e.g. a TCP socket), Transform (Duplex where output is a function of input, e.g. zlib.createGzip()). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#5-streams-and-backpressure]
highWaterMark and the write() / drain contract
- Each stream has a highWaterMark buffer threshold - default 16384 bytes (16 KB) for byte streams, 16 objects in objectMode. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#highwatermark-and-the-write-drain-contract]
- writable.write(chunk) returns true → keep writing. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#highwatermark-and-the-write-drain-contract]
- It returns false → the internal buffer is at/over highWaterMark. Stop writing and wait for the 'drain' event before resuming. (write() still accepts the chunk; the return value is purely the backpressure signal.) [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#highwatermark-and-the-write-drain-contract]
- Real impact: compressing a ~9 GB file with backpressure held memory at ~88 MB; ignoring it ballooned to ~1.5 GB (≈17× more) with far worse GC pauses. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#highwatermark-and-the-write-drain-contract]
Prefer pipeline() over pipe()
- pipe() and pipeline() handle backpressure automatically (you don't manage drain/pause/resume). Always prefer stream.pipeline() over manual .pipe() chains: on any stream's failure it destroys all streams and propagates the error, where .pipe() leaks file descriptors and sockets on error. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#prefer-pipeline-over-pipe]
Custom stream rules
- Readable._read: respect push()'s return value - when this.push(chunk) returns false, stop pushing (the consumer's buffer is full). push(null) signals end-of-stream. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#custom-stream-rules]
- Writable._write(chunk, enc, cb): call cb exactly once (use return cb() on every branch so it can't be called twice). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#custom-stream-rules]
- Batching: cork() buffers writes; uncork() flushes them in one go. Schedule the uncork() with process.nextTick() so multiple synchronous write()s batch into a single flush rather than flushing per call. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#custom-stream-rules]
- Modern alternative: build pipelines from async iterators / async generators as Transform stages - pipeline() accepts them and applies backpressure automatically. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#custom-stream-rules]
Web Streams API (WHATWG) & Node interop (Readable.toWeb/fromWeb, stream/consumers)
- Alongside classic node:stream (Section 5), Node implements the WHATWG Web Streams standard - the same ReadableStream/WritableStream/TransformStream you get in browsers, fetch/undici response bodies, and edge runtimes (Workers, Deno). Use them for cross-runtime / edge-portable code and when consuming fetch bodies; reach for classic node:stream for Node-centric server I/O and the richer ecosystem. The three stream classes plus TextEncoderStream/TextDecoderStream/CompressionStream/DecompressionStream/the queuing-strategy classes are globals in modern Node - no import. Only node:stream/consumers, node:stream/promises, and the toWeb/fromWeb bridge methods need importing (Web Streams API, MDN Streams concepts). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#web-streams-api-whatwg-node-interop-readabletowebfromweb-streamconsumers]
Construction (underlying source / sink / transformer)
- Web Streams take a plain object describing the source/sink, optionally followed by a queuing strategy. The hook names differ from classic _read/_write (Section 5): [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#construction-underlying-source-sink-transformer]
Backpressure: the demand/pull model (vs classic write()/drain)
- Same goal as Section 5 (bound memory, throttle a fast producer) but a different surface - so don't re-derive the classic write()===false + 'drain' contract; note the delta: [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#backpressure-the-demandpull-model-vs-classic-writedrain]
- A queuing strategy is an object (new ByteLengthQueuingStrategy({highWaterMark}) for byte streams, new CountQueuingStrategy({highWaterMark}) for object streams), not a numeric highWaterMark option as in classic streams. There is no fixed default highWaterMark - the model, not a magic number, is the contract (classic streams keep the 16 KB / 16-object defaults from Section 5). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#backpressure-the-demandpull-model-vs-classic-writedrain]
- Backpressure is read via controller.desiredSize (= highWaterMark − queued size). When it drops to ≤ 0, the producer is outrunning the consumer; stop enqueuing. It can go negative. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#backpressure-the-demandpull-model-vs-classic-writedrain]
- The runtime calls pull(controller) only while the queue is below the high-water mark, and - if pull returns a Promise - won't call it again until that Promise settles. This is a demand-driven pull loop, whereas classic streams are push-with-a-drain-backstop. Apply backpressure on the write side by returning a Promise from the sink's write() (or awaiting writer.ready / reading writer.desiredSize). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#backpressure-the-demandpull-model-vs-classic-writedrain]
- stream.pipeTo() / stream.pipeThrough() propagate backpressure end-to-end automatically (the Web-Streams analogue of pipeline()), and tee() forks one readable into two independent readables - a fan-out classic streams have no direct equivalent for. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#backpressure-the-demandpull-model-vs-classic-writedrain]
Default vs BYOB (byte) readers
- getReader() yields a ReadableStreamDefaultReader (opaque chunks). For a type: 'bytes' stream, getReader({ mode: 'byob' }) yields a ReadableStreamBYOBReader whose read(view) fills a caller-supplied TypedArray/DataView - a zero-copy read straight into your buffer. (Never hand it a pooled Node Buffer: BYOB detaches the backing ArrayBuffer.) Async iteration works too: for await (const chunk of readable). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#default-vs-byob-byte-readers]
Bridging classic ↔ Web (toWeb / fromWeb)
- Static methods on the classic stream classes convert both directions - bridge a Node file/socket stream into a Web pipeline, or wrap a fetch body as a classic Readable: [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#bridging-classic-web-toweb-fromweb]
- These bridges were historically flagged experimental - check the stability index for your Node version and pin Node versions for production use. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#bridging-classic-web-toweb-fromweb]
Consuming either stream world: stream/consumers & stream/promises
- node:stream/consumers collects either a Web ReadableStream, a classic stream.Readable, or any AsyncIterable into one value - no manual chunk loop: [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#consuming-either-stream-world-streamconsumers-streampromises]
- node:stream/promises spans both worlds too: pipeline() accepts Web streams, classic streams, and async iterables/generators in one chain, and finished() accepts a ReadableStream/WritableStream as well as a classic stream. Prefer these over hand-rolled getReader()/read() loops when you just need the collected result or a completion signal. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#consuming-either-stream-world-streamconsumers-streampromises]
6. Three ways to escape the single thread
- Rule of thumb: CPU-bound JS → worker_threads; scaling an HTTP/TCP server → cluster; shelling out to another program → child_process. None of these help I/O-bound work - plain async I/O on one thread is already optimal and cheaper. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#6-three-ways-to-escape-the-single-thread]
7. worker_threads
- Each Worker is a separate V8 isolate with its own event loop and heap, inside the same OS process - far cheaper than a process, and able to share memory (worker_threads). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#7-worker_threads]
- postMessage() copies data using the HTML structured clone algorithm (not JSON): it handles Map/Set/Date/RegExp/BigInt/typed arrays and circular refs, but drops class prototypes (a class instance arrives as a plain object) and cannot clone functions. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#7-worker_threads]
- transferList: move (don't copy) ownership of an ArrayBuffer / MessagePort: port.postMessage(view, [view.buffer]). The buffer becomes detached (length 0) on the sender side, and all views over it become unusable - zero-copy handoff. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#7-worker_threads]
- MessageChannel / MessagePort: dedicated bidirectional channels (transfer one port to the worker). BroadcastChannel: one-to-many by channel name. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#7-worker_threads]
- SharedArrayBuffer + Atomics: true shared memory for high-frequency coordination. Use Atomics.add/compareExchange/... for race-free updates and Atomics.wait / Atomics.notify to block/wake threads. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#7-worker_threads]
- Caveats: workers don't share process.stdin/stdout/stderr unless piped; can't process.chdir() or handle process signals; worker.unref() lets the process exit without waiting on the worker; worker.terminate() force-stops it (returns a Promise). For many small tasks, reuse a worker pool (e.g. Piscina) rather than creating a worker per task. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#7-worker_threads]
8. cluster
- cluster forks multiple Node worker processes that all listen on the same server port, letting a server use every core. It is built on child_process.fork() with an IPC channel and server-handle passing (cluster). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#8-cluster]
- Scheduling policy (cluster.schedulingPolicy / NODE_CLUSTER_SCHED_POLICY): [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#8-cluster]
- SCHED_RR (round-robin) - default everywhere except Windows. The primary accepts connections and hands them out evenly. Usually the right choice. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#8-cluster]
- SCHED_NONE - the OS distributes connections; can be badly unbalanced (e.g. most connections landing on a couple of workers). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#8-cluster]
- Lifecycle events (primary): fork, online, listening, message, disconnect, exit. Communicate via worker.send() / process.on('message'). Graceful shutdown: worker.disconnect() (stop accepting, drain) then a kill-timeout fallback. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#8-cluster]
- Caveats: workers have separate memory - never keep session/login state in process memory; use a shared store (Redis) or a load balancer with sticky sessions for stateful connections. cluster.isMaster/setupMaster() are deprecated → use isPrimary/setupPrimary(). Many deployments instead run N single-process instances behind an external balancer (or a process manager like PM2). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#8-cluster]
9. child_process
- Run other programs (or other Node scripts) as separate OS processes (child_process). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#9-child_process]
- Each has a *Sync variant (spawnSync, execSync, execFileSync) that blocks the event loop - startup/CLI use only, never in a server. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#9-child_process]
- Command injection: exec/shell: true interpolate strings through a shell, so untrusted input enables injection (exec(\echo ${userInput}\) with userInput = "; rm -rf /"). Prefer spawn/execFile with an args array - arguments bypass shell parsing, so metacharacters are inert. Reach for a shell only when you genuinely need shell features, and sanitize input. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#9-child_process]
- Other essentials: [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#9-child_process]
- maxBuffer (exec/execFile): exceeding it kills the child with ERR_CHILD_PROCESS_STDIO_MAXBUFFER_EXCEEDED; raise it or switch to spawn for big output. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#9-child_process]
- stdio option: 'pipe' (default; streams on the child object), 'inherit' (share the parent's stdio), 'ignore', or 'ipc' (message channel - what fork adds). [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#9-child_process]
- IPC: fork (or spawn with an 'ipc' stdio slot) gives child.send(msg) ↔ process.on('message'), using structured clone. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#9-child_process]
- detached: true + subprocess.unref() lets a child outlive the parent (with stdio: 'ignore'). ref()/unref() toggle whether the child keeps the parent's loop alive. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#9-child_process]
- Events: 'spawn' (started) → 'exit' (process ended, stdio may still be open) → 'close' (stdio fully closed; always after 'exit'); 'error' on spawn failure. [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#9-child_process]
References
- Node.js - The event loop, timers, and process.nextTick() [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#references]
- Node.js - Don't block the event loop (or the worker pool) [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#references]
- Node.js - Backpressuring in streams [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#references]
- Node.js Stream API [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#references]
- Node.js Web Streams API (node:stream/web, stream/consumers, stream/promises) [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#references]
- MDN - Streams API concepts (backpressure, queuing strategies, BYOB) [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#references]
- Node.js worker_threads [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#references]
- Node.js cluster [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#references]
- Node.js child_process [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#references]
- libuv - Design overview [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#references]
- libuv - Thread pool work scheduling [archived source: https://llms-explorer.com/sources/mdb-context-hub/nodejs-concurrency-internals/#references]
Where this helps
- Diagnosing why a Node server's latency climbs with no obvious CPU spike, a thread-pool saturation symptom from too many concurrent fs/crypto/zlib/DNS operations queueing behind the default 4 threads. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
- Deciding how to escape the single main thread for a specific workload: worker_threads for CPU-bound JS, cluster for scaling an HTTP server across cores, child_process for shelling out to another program. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
- Streaming a large file or payload, such as a multi-GB upload or a big compression job, without memory ballooning, by respecting write()'s backpressure signal or using pipeline() instead of manual pipe() chains. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
- Explaining why setImmediate always fires before setTimeout(...,0) inside an I/O callback but not at the top level, a recurring source of confusing, seemingly-nondeterministic bug reports. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
Project ideas
- Build a worker_threads pool, or adopt Piscina, for CPU-bound JS work like image processing or PDF generation, so requests aren't blocked by synchronous computation on the main thread. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
- Build an event-loop-lag monitor using perf_hooks.monitorEventLoopDelay() or performance.eventLoopUtilization() and alert when lag crosses a threshold, catching blocking code before customers notice. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
- Build a streaming file-processing pipeline using stream.pipeline(), or the Web Streams pipeTo()/pipeThrough(), instead of buffering whole files in memory, and verify memory stays bounded under load. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
- Build a cluster-based multi-core HTTP server with round-robin scheduling and a shared Redis session store, since cluster workers don't share process memory. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
Antipatterns
- Recursively scheduling process.nextTick() or microtasks: because these queues fully drain before the loop advances, a recursive nextTick call starves I/O, timers, and setImmediate indefinitely. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
- Calling synchronous APIs, such as fs.readFileSync, crypto.pbkdf2Sync, or JSON.parse on a huge payload, inside a request handler: this blocks the single main thread and stalls every other concurrent client. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
- Spawning a new worker_thread per incoming request instead of reusing a bounded worker pool: this is an easy path to a fork-bomb-style memory blowout under load. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
- Using .pipe() instead of stream.pipeline() for a production data flow: .pipe() leaks file descriptors and sockets when a stream in the chain errors, where pipeline() destroys all streams and propagates the error cleanly. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
Known issues
- dns.lookup(), which most connection code calls implicitly, uses the same libuv thread pool as fs and crypto operations, so a burst of DNS lookups can starve file and crypto work sharing that pool. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
- Custom Writable._write callbacks must be called exactly once; calling cb() on more than one code branch is a subtle bug that can corrupt stream state. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
- worker_threads' postMessage() uses the structured clone algorithm, which drops class prototypes (a class instance arrives as a plain object) and cannot clone functions at all. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
- The Web Streams API's toWeb() and fromWeb() bridges between classic Node streams and WHATWG streams have historically been flagged experimental, so their stability needs checking per Node version before relying on them in production. [source] — AI-suggested, synthesized from this pack's existing facts — not extracted from a source document.
Children
- libuv event loop phases (timers, pending callbacks, idle/prepare, poll, check, close) (frontier)
- Microtask ordering (process.nextTick queue + Promise microtask queue, starvation) (frontier)
- setTimeout(0) vs setImmediate ordering (frontier)
- libuv thread pool (UV_THREADPOOL_SIZE 4->1024; fs/dns.lookup/crypto/zlib) (frontier)
- Don't block the event loop (sync APIs, ReDoS, partitioning vs offloading, event-loop lag) (frontier)
- Streams and backpressure (highWaterMark, write()/false, drain, pipe vs pipeline) (frontier)
- Stream types and custom streams (Readable/Writable/Duplex/Transform, push/write rules, cork/uncork) (frontier)
- worker_threads (V8 isolates, structured clone, transferList, SharedArrayBuffer/Atomics, MessageChannel) (frontier)
- cluster (SCHED_RR vs SCHED_NONE, shared server ports, worker lifecycle, built on child_process.fork) (frontier)
- child_process (spawn/exec/execFile/fork, shell command-injection, maxBuffer, IPC, detached/unref) (frontier)
- Web Streams API (WHATWG) & Node interop (ReadableStream/WritableStream/TransformStream, Readable.toWeb/fromWeb, stream/consumers) (frontier)
Frontier under this node: Don't block the event loop (sync APIs, ReDoS, partitioning vs offloading, event-loop lag), Microtask ordering (process.nextTick queue + Promise microtask queue, starvation), Stream types and custom streams (Readable/Writable/Duplex/Transform, push/write rules, cork/uncork), Streams and backpressure (highWaterMark, write()/false, drain, pipe vs pipeline), Web Streams API (WHATWG) & Node interop (ReadableStream/WritableStream/TransformStream, Readable.toWeb/fromWeb, stream/consumers), child_process (spawn/exec/execFile/fork, shell command-injection, maxBuffer, IPC, detached/unref), cluster (SCHED_RR vs SCHED_NONE, shared server ports, worker lifecycle, built on child_process.fork), libuv event loop phases (timers, pending callbacks, idle/prepare, poll, check, close), libuv thread pool (UV_THREADPOOL_SIZE 4->1024; fs/dns.lookup/crypto/zlib), setTimeout(0) vs setImmediate ordering, worker_threads (V8 isolates, structured clone, transferList, SharedArrayBuffer/Atomics, MessageChannel)