Node.js — clustering

The cluster module's accept-and-handoff, rebuilt on worker_threads: the main thread accepts TCP connections and transfers each net.Socket to a pool worker before reading a single byte. It is a dumb acceptor — every part of the request lifecycle runs off-thread.

Main thread

main.tsbackend
// main thread — pure connection offload (Node ≥ 26)
import { createHttpCluster } from '@atolljs/node/http';

const cluster = createHttpCluster({ pool, port: 3205 });
// net.createServer({ pauseOnConnect: true }) accepts the socket,
// transfers it round-robin — parsing, routing, handlers, and
// response serialization all happen inside the worker's http.Server.
// Below Node 26 the call logs a notice and returns null — no
// capability check needed in caller code; cluster?.close() covers it.

// Sticky variant — multi-connection session flows (socket.io's
// polling→upgrade, HTTP↔WS pairs) need every connection from a client
// on the same worker. The acceptor can't read cookies, so the key is
// the client address — rendezvous hashing, nginx ip_hash style:
import { stickyByAddress } from '@atolljs/node/http';
createHttpCluster({ pool, port: 3206, route: stickyByAddress() });

Worker side

serveHttp attaches the transferred sockets to a worker-owned http.Server alongside the task bootstrap — one worker file, two protocols. Any (req, res) listener works: Express, Koa .callback(), fastify().server.

offload.worker.tsbackend
// offload.worker.ts — shim first, always
import '@atolljs/node/shim';
import '@atolljs/incidents/worker/incidents.worker'; // task handlers
import { serveHttp } from '@atolljs/node/http';
import { createApp } from './app';                  // Express inside the worker

serveHttp(createApp(), { listen: 0 });

Semantics

  • Per-connection routing — the acceptor never parses HTTP, so it can't split a port by URL path. Path-level ownership needs the gateway instead.
  • Round-robin by default — a route(socket, workers) option picks the worker per connection (e.g. hash on remote address). Returning undefined drops the connection.
  • Stickiness — one transferred socket pins for life, so a bare WS connection needs nothing. Multi-connection sessions (socket.io polling→upgrade, HTTP↔WS pairs) need route: stickyByAddress() — rendezvous hashing on the client address; removing a worker only remaps its own clients.
  • Self-gating — net.Socket/net.Server transfer landed in Node 26. Below it, createHttpCluster logs a notice and returns null — no caller-side check. (SOCKET_TRANSFER_SUPPORTED stays exported for tests/feature detection.)
  • Plain HTTP only — a TLS handshake would consume bytes on the accepting thread. Terminate TLS upstream or serve behind a proxy.
  • WebSockets ride along — the upgrade handshake lives inside the transferred socket, so a WebSocketServer attached to the worker's server works unchanged. See WebSockets.
  • Multiplexed workers — the same workers still answer EXECUTE_TASK dispatch; sockets queue behind whatever a worker is doing.

Live example

examples/http-offload serves the clustered listener on :3205 (Node ≥ 26) next to the gateway on :3204 — same workers, same app. The NestJS variant is Backend → NestJS → Clustering.