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.
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/s | p99 per call | sockets | |
|---|---|---|---|
httpTransport(), tuned pool | ~15.6k | 10–13 ms | 65 |
tcpTransport() | ~91k | 1.3 ms | 3 |
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:
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
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/hostis just a route a transport declares, so droppinghttpTransport()from the chain removes it — smaller attack surface, nothing tocurl. 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
- TCP transport — the Node default choice.
- Clustering — where the transport plugs in.
- The client — the other seam, browser to host.
