Host transports#

How hosts talk to each other is a seam. HTTP is the default and the only portable option; on Node, hosts should run TCP.

TypeScript
cluster({ providers, advertise, secret, transport: [tcpTransport(), httpTransport()] });

Hosts run TCP#

On Node, chain [tcpTransport(), httpTransport()] for the host-to-host link. The reason is latency and CPU per hop as well as socket count.

Per hop, one machine, five runs, against HTTP with a tuned pool:

calls/sp99 per callsockets
httpTransport(), tuned pool~15.6k10–13 ms65
tcpTransport()~91k1.3 ms3

Across a fleet — sixteen hosts under an engine-bound workflow load — TCP completed 1.4–1.9× the runs per second past 200 offered, with start-latency p99 under 200 ms where HTTP's was over 100 s. HTTP's per-peer fetch pool saturated, calls timed out and retries duplicated directory claims: dispatch retries went from 10,184 on HTTP to 16 on TCP, and claim conflicts from 16,024 to 16. One multiplexed connection per peer has no pool to saturate.

On the client-facing socket axis the effect is smaller but in the same direction: at 5,000 and 10,000 live subscribers, TCP between hosts delivered 4–5% more per second at 15–23% lower p50 and p99.

Socket count still matters on its own: at concurrency 64 across 99 peers, HTTP wants roughly 12,600 sockets per host. TCP wants 99.

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#

@sigx/actors/cluster is zero-dependency and WinterCG-clean so Cloudflare Workers keep working, and HTTP is the only transport that runs everywhere. It is also the client endpoint, and the fallback the chain above drops to for a peer that does not advertise TCP — which is what makes a rolling deploy of TCP safe.

Bounding the HTTP pool#

Node's global fetch uses an unbounded undici pool, measured at two sockets per in-flight request. Bounding it is the cheapest improvement available, and @sigx/actors/node ships it as boundedFetch:

TypeScript
import { boundedFetch } from '@sigx/actors/node';

cluster({
    providers, advertise, secret,
    fetch: boundedFetch({ connections: 64 }),   // match your outbound concurrency
});

cluster({ fetch }) is sugar for transport: httpTransport({ fetch }). The returned fetch forwards every call through one undici Agent({ connections }) as its dispatcher. It is scoped: only that fetch is re-tuned, never the process's global dispatcher, so your app's own outbound HTTP and other SDKs are untouched.

connections is required and has no default — the right value is your host's outbound concurrency, and a positive integer is enforced at construction. undici is an optional peer dependency (>=6) of @sigx/actors: install it next to it (pnpm add undici). It is loaded lazily on the first call, so a host that never calls the returned fetch never loads it, and a missing install fails that first call with install guidance rather than a bare module-not-found.

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.

boundedFetch lives on ./node rather than ./cluster so the cluster entry stays zero-dependency and WinterCG-clean. On another runtime, pass any tuned fetch the same way.

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.

One rule in it matters more than the rest: answer unreachable only for a call that provably never executed — no connection accepted it, or the request was provably never written. The placement retries unreachable, so a transport that uses it for a call that was in flight when its connection dropped is asking for a non-idempotent method to run twice. That case rejects with an ordinary Error — outcome unknown, not retried — and the caller decides.

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#