Module A-7·27 min read

cluster vs worker_threads, SharedArrayBuffer ring buffers for zero-copy IPC, and parallelising transaction signature verification.

Module 6 — Core Scaling: Multi-Process Clustering & IPC Latency

What this module covers: A single Node.js process is single-threaded. On a 32-core cloud instance, that means 31 cores sit idle while your blockchain indexer saturates the 32nd. cluster and worker_threads are the two mechanisms for using those cores — but they have fundamentally different properties, different communication overhead, and different failure modes. Choosing the wrong one for your workload costs 3–10x throughput. This module covers the exact internal mechanics of both, the precise cost of IPC serialization at high message rates, and how SharedArrayBuffer + Atomics eliminates that cost for the right workloads.


Why a Single Node.js Process Cannot Saturate a Multi-Core Server

The event loop runs on one thread. JavaScript runs on one thread. V8 runs on one thread. A 32-core server with Node.js running on it has 31 cores available for your application — and by default, 31 of them are idle.

This is by design, not a limitation. The single-threaded model is what makes the event loop's performance guarantees possible: no shared state, no mutex contention, no thread-safety bugs. But for CPU-intensive workloads on large instances, you need to take deliberate action to use the hardware you're paying for.

Two mechanisms exist:

  • cluster: forks full Node.js processes. Each has its own V8 heap, event loop, and thread. Complete process isolation.
  • worker_threads: creates threads within a single process. Shared memory space. Communication via message passing or SharedArrayBuffer.

The right choice depends entirely on your workload characteristics.


cluster: Full Process Replication

cluster.fork() creates a complete copy of the Node.js process. The child process has its own V8 heap, its own event loop, its own module registry, and its own memory space.

javascript

Cluster Load Balancing: Round-Robin vs SO_REUSEPORT

Default (round-robin by master): The master process accepts all incoming connections and distributes them to workers via IPC. This creates a bottleneck at the master process and adds IPC overhead per connection.

SO_REUSEPORT (recommended): Each worker binds directly to the port. The kernel distributes connections between workers using a hash of the connection 4-tuple (src IP + src port + dst IP + dst port). No IPC for connection distribution. No master bottleneck.

javascript

Why SO_REUSEPORT is better for blockchain indexers:

The master-round-robin model concentrates the accept syscall on one process. At 50K connections/second, the master spends all its time calling accept4 and sending socket handles to workers via IPC. With SO_REUSEPORT, 32 workers each accept 1,562 connections/second — well within the capacity of each worker's event loop.

The Memory Cost of cluster

Each forked worker is a separate process. On Linux, fork() uses copy-on-write, so immediately after forking, the child shares the parent's pages. As each worker modifies pages (loading modules, creating objects), those pages are copied — the shared advantage erodes over time.

For a typical Node.js application with a 200MB heap, 32 workers will consume:

  • Immediately after fork: ~200MB shared + ~50MB unique per worker = ~1.8GB
  • After warmup (1–2 min): ~200MB shared + ~150MB unique per worker = ~5GB

On a 32GB server, 32 workers at 5GB total leaves 27GB for OS page cache and application data — acceptable. On a 16GB server with a large application, cluster may not be viable.


child_process: Spawning External Processes

For integrating with non-JavaScript components (a Rust binary for signature verification, a Python analytics script, a Go RPC service), child_process provides three key APIs.

spawn: Streaming I/O

javascript

spawn is for processes that produce large output or need streaming I/O. stdout/stderr are Readable streams — backpressure applies.

fork: V8-to-V8 IPC

fork is spawn specialized for Node.js child processes. It creates a communication channel (IPC) that supports process.send() / process.on('message').

javascript

The IPC cost: every process.send() call serializes the message using JSON.stringify (or structuredClone for transferables), sends it over a Unix socket, and deserializes on the other side. For small messages (< 1KB) at low frequency (< 1,000/sec), this is fine. At high frequency with large payloads, it becomes the bottleneck — see the IPC latency section below.


worker_threads: Threads with Shared Memory

Unlike cluster (separate processes), worker_threads creates threads within the same process. Threads share:

  • The same process address space
  • The same libuv thread pool
  • The ability to share memory via SharedArrayBuffer

Threads do NOT share:

  • The V8 heap (each thread has its own heap)
  • The event loop (each thread has its own loop)
  • Global JavaScript state
javascript

worker_threads vs cluster: The Decision Matrix

clusterworker_threads
Process isolationComplete — crash in worker doesn't affect othersPartial — uncaught exception in worker crashes worker thread
Memory sharingNone (copy-on-write after fork)SharedArrayBuffer for zero-copy
IPCUnix socket + JSON serializationpostMessage (structured clone) or shared memory
Use forHTTP server scaling, independent workloadsCPU-bound computation, shared state
Module reloadEach worker loads modules independentlyWorkers can share module instances
Failure isolationStrong — OOM in one worker doesn't affect othersWeaker — shared libuv pool, shared process memory

For a blockchain indexer:

  • HTTP request handling → cluster (each worker independently accepts connections)
  • Transaction signature verification → worker_threads (CPU-bound, benefits from shared memory for batch data)
  • Historical block replay → worker_threads (high computation, can share the block buffer via SharedArrayBuffer)

IPC Latency: The Hidden Cost of postMessage

worker_threads.postMessage() and process.send() both serialize data before transmission.

The default serialization uses the Structured Clone Algorithm — similar to JSON.stringify but handles more types (Map, Set, ArrayBuffer, etc.). The cost:

javascript

Typical results by payload size:

PayloadRound-trip latencyThroughput
100 bytes0.05ms20,000 msg/sec
1 KB0.12ms8,300 msg/sec
10 KB0.8ms1,250 msg/sec
100 KB7ms143 msg/sec
1 MB65ms15 msg/sec

For a blockchain indexer passing 5KB transaction payloads at 50K/sec: IPC throughput = 1,250 msg/sec × N workers. At 8 workers: 10,000 transactions/sec max via message passing. Insufficient for 50K/sec.

The solution: SharedArrayBuffer for bulk data.


SharedArrayBuffer + Atomics: Zero-Copy IPC

SharedArrayBuffer allocates memory that is simultaneously accessible from multiple threads. No serialization. No copying. Reads and writes are direct memory operations.

A Production Ring Buffer for Transaction Batching

javascript
javascript
javascript

Throughput comparison:

  • postMessage with 5KB payload: ~8,300 msg/sec
  • SharedArrayBuffer ring buffer: ~850,000 writes/sec (100x faster)

The ring buffer is particularly effective when the main thread is ingesting faster than workers can consume — the shared memory acts as a natural back-buffer without any allocation.


Structured Concurrency: Managing Worker Pools Gracefully

A worker pool without lifecycle management leaks workers, misses errors, and fails ungracefully on shutdown.

javascript

The Production Incident: IPC Bottleneck Hiding as a CPU Problem

Context: A blockchain indexer using cluster with 16 workers. Each worker receives transaction events via IPC from the primary process, processes them, and writes to PostgreSQL.

The symptom: Throughput plateaued at 4,200 transactions/second on a 16-core server. CPU utilization across all cores: 15%. Neither the database nor the network was the bottleneck. Adding more workers didn't help.

The diagnosis:

bash

Each cluster.fork() worker communication was going through the master's IPC loop. The primary process was serializing incoming transaction data with JSON.stringify, sending it via process.send() to 16 workers, and receiving acknowledgements back. At 4,200 transactions/second, the primary was doing:

  • 4,200 JSON.stringify calls/sec (each ~5KB payload = 21MB/sec of serialization)
  • 4,200 sendmsg syscalls/sec
  • 4,200 recvmsg syscalls/sec
  • 4,200 JSON.parse calls on each worker = 16 × 4,200 = 67,200 parses/sec

The primary's event loop hit ELU 0.97. The primary was the bottleneck, not the workers.

The fix — switch to SO_REUSEPORT so workers receive connections directly:

javascript

After the fix: throughput jumped to 28,000 transactions/second. Primary ELU dropped to 0.02. CPU across workers: 65% (healthy, room to grow). The IPC overhead had consumed 85% of the primary's capacity, visible only via strace and ELU measurement.


Summary

ConceptKey Takeaway
clusterFull process fork. Complete isolation. Memory: ~150MB extra per worker after warmup.
SO_REUSEPORTWorkers bind port directly. Kernel distributes connections. Eliminates master bottleneck.
child_process.spawnStreaming I/O to external processes. Use for Go/Rust/Python integrations.
child_process.forkNode-to-Node IPC. JSON serialization over Unix socket. Avoid for high-frequency messages.
worker_threadsThreads with own V8 heap. postMessage for messages, SharedArrayBuffer for bulk data.
IPC postMessage cost~0.05ms for 100 bytes, ~7ms for 100KB. 8,300 msg/sec max for 1KB payloads.
SharedArrayBufferZero-copy shared memory. 850K+ writes/sec for 256-byte slots. 100x faster than postMessage for bulk data.
Atomics.wait/notifyThread synchronization on SharedArrayBuffer. Wait without spinning.
Worker pool lifecycleHandle worker errors, replace crashed workers, drain on SIGTERM.
IPC bottleneck patternELU 0.97 on primary, low ELU on workers, plateau in throughput → primary is the bottleneck.

Clustering gets you horizontal scale across cores. Module 7 covers the routing layer — what happens to every incoming request before it reaches your business logic, and why the router you choose can cost you 3x throughput before a single line of your code runs.

Next: Module 7 — Routing Engines at Scale: Vanilla HTTP vs Radix Tree Frameworks →


Knowledge Check

When migrating a Node.js cluster application to achieve higher throughput on a multi-core machine, why does switching to SO_REUSEPORT significantly improve performance over traditional cluster.fork() with IPC?


Which mechanism provides the highest throughput for transferring bulk data between the main thread and a worker_threads worker?


What is the primary purpose of Atomics.wait() in a worker consuming data from a SharedArrayBuffer ring buffer?

Test your knowledge with more question sets

Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.