Host transports#

How hosts talk to each other is a seam. HTTP is the default and the only portable option; TCP collapses connection count on Node.

TypeScript
cluster({ providers, advertise, secret, transport: httpTransport({ fetch: tuned }) });

The measured comparison#

Portable?Connections per peerRelative throughput
httpTransport() (default), unbounded pooleverywhere, incl. Workers2 × concurrency0.9×
httpTransport() with a bounded pooleverywhere, incl. Workersconcurrency1.0× (baseline)
tcpTransport()Node only14.9×

Throughput is relative to bounded HTTP, because that is the fair baseline — the shipped default is the first row, and bounding it is a free improvement.

On Node, prefer TCP. It is the socket transport for hosts.

This page is about host-to-host traffic. The transport a browser uses to reach a host is a different seam — fetchTransport() by default, or socketTransport() from @sigx/actors-ws. The two never meet.

HTTP stays the default, and must. @sigx/actors/cluster is zero-dependency and WinterCG-clean so Cloudflare Workers keep working, and HTTP is the only transport that runs everywhere.

Two caveats worth carrying#

The throughput ratio is a loopback software number. TCP is ~70µs/call against ~14µs — a 56µs difference that a real LAN round trip of 200–1000µs largely absorbs. On a real network that is about 1.1×, not 4.9×. Do not publish "4.9× faster" unqualified.

The connection-count win is the one that survives a real network, and it is usually the reason to move: at concurrency 64 across 99 peers, HTTP wants roughly 12,600 sockets per host. TCP wants 99.

Bounding the HTTP pool#

Node's global fetch uses an unbounded undici pool. Bounding it is the cheapest improvement available:

TypeScript
import { Agent, fetch as undiciFetch } from 'undici';

const agent = new Agent({ connections: 64 });   // match per-peer concurrency

cluster({
    providers, advertise, secret,
    transport: httpTransport({
        fetch: (url, init) => undiciFetch(url, { ...init, dispatcher: agent }),
    }),
});

Match connections to your per-peer concurrency. Measured against undici 7.x — what current Node bundles — at concurrency 64: the unbounded default opens 128 sockets; connections: 64 opens 64 and runs 6% faster; connections: 8 costs about 3× throughput; connections: 1 costs ~57%.

So halving the sockets is free, and going below your concurrency trades throughput steeply — worth it only when file descriptors are the real constraint.

These numbers are undici-major-specific. Figures measured on undici 8.x do not hold.

HTTP/2 does not help, and it is worth knowing why before you try it. allowH2: true measures identical to plain keep-alive at every pool size, because createAppHandler serves over node:http — HTTP/1.1 only — so the client negotiates nothing and falls back. Multiplexing would need a node:http2 server first: a much larger change than the pool cap, for the same reduction in sockets.

undici is a recipe, not an API — it is not a dependency of @sigx/actors.

Fallback chains make adoption safe#

TypeScript
transport: [tcpTransport({ port: 11111 }), httpTransport()];

A transport publishes its peer-reachable address under addresses[name] in the host descriptor, and a list is tried in order. Mid-deploy, hosts advertising the new transport use it and the rest are reached over HTTP — no window of unreachability.

A descriptor without addresses reads as HTTP-only. A single transport is strict: a peer advertising no address for it is unreachable, loudly. That is deliberate — a silent fallback means you deploy a transport, benchmark it, and measure the old one without knowing.

Fallbacks that do occur are counted (counters().transportFallbacks), reported in the host report, and dev-warned once per peer.

A socket-only cluster has no internal HTTP endpoint at all. /_sigx/host is just a route a transport declares, so dropping httpTransport() from the chain removes it — smaller attack surface, nothing to curl. Ops runbooks that poke that path are assuming the HTTP transport.

The public actor wire is unaffected by any of this.

Writing one#

transportConformance holds the cases a transport must pass — supply a harness that builds an N-host cluster over your wire and every case runs against it. The rule it enforces throughout is assert on the error kind, never on an HTTP status, which is what makes the contract expressible off HTTP at all.

It is currently contributor-facing only: reachable inside the actors workspace as @sigx/actors/cluster/testing through a tsconfig and vitest alias, but not in the published exports map, so an out-of-repo package cannot import it yet.

The frame codec, however, is published: @sigx/actors/cluster/frames carries it, including the message-oriented FrameLink variant. A WebSocket host-to-host transport, or anything else message-shaped, can be built on it outside this repo.

Next steps#