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 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.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). Returningundefineddrops 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.Servertransfer landed in Node 26. Below it,createHttpClusterlogs a notice and returnsnull— no caller-side check. (SOCKET_TRANSFER_SUPPORTEDstays 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
WebSocketServerattached to the worker's server works unchanged. See WebSockets. - Multiplexed workers — the same workers still answer
EXECUTE_TASKdispatch; 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.