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