Running in production
Everything on this page is about the boundary between your process and the infrastructure around it: how a deploy drains, what a load balancer needs to know, and which options stop being defaults once there is more than one instance.
For the threat-model side, see Security. For what to watch, see Logging and OpenTelemetry.
Graceful shutdown
A deploy that closes sockets mid-call turns a rollout into a burst of errors. close({ drain }) sequences the shutdown instead:
const stop = async () => {
await server.close({ drain: 10_000 });
await backplane.close(); // yours to close — wrpc never closes an injected one
process.exit(0);
};
process.on('SIGTERM', stop);
process.on('SIGINT', stop);What that does, in order:
- Intake stops. The listener closes, so the next health check fails while in-flight work is still finishing.
rpc.drainingflips true. New calls answer503— the code that tells a well-behaved client to reconnect somewhere else.- In-flight calls get up to
drainms. It resolves early the moment nothing is in flight. - Every peer gets a
1001"going away" close frame, before the core evicts anyone. A client that receives 1001 reconnects on its own with backoff. - What remains is torn down, and open connections are closed hard.
Subscriptions are not waited for
A live feed has no natural end, so drain deliberately does not wait for one — it is ended by the close that follows. Size drain against your slowest call, not your longest subscription.
Without drain, the same sequence runs with a zero-length window.
Health checks
Point liveness at the process and readiness at the listener. Because intake stops first, a readiness probe against the bound port fails as soon as the shutdown begins — which is exactly when you want the load balancer to stop sending new connections.
await server.listen();
const { port } = server.address();Read the address through server.address()
With a standalone engine (uWebSockets.js) there is no node:http server at all: server.httpServer is null, and server.httpServer.address() throws. server.address() covers both shapes.
Naming the instance
const server = new Server({
router,
backplane,
instanceId: process.env.POD_NAME, // stable across restarts, no '.' allowed
});instanceId defaults to a fresh UUID per process. Setting it to something your orchestrator already knows makes cluster presence, descriptors and logs readable — node-1 instead of a UUID.
It must not contain a ., because a client id is <instanceId>.<generateId()> and the dot is the separator an addressed command splits on. Restart detection does not depend on the id being fresh: every boot carries a new epoch.
Client and packet ids
generateId replaces the default UUID everywhere ids are minted — client ids, call ids, stream ids:
let counter = 0;
const generateId = () => `${counter++}`; // dense, short, per process
new Server({ router, generateId, instanceId: 'node-1' });Two rules: it must return a string of at most 255 characters, and ids must be unique within an instance — the instanceId prefix is what makes them unique across the cluster. Short ids measurably shrink per-packet bytes on chatty connections; UUIDs are the safe default when you have not thought about it.
The client takes the same option for its own call ids.
Behind a proxy
- TLS. Either run
protocol: 'https'withkey/cert, or terminate TLS at the proxy and run'http'behind it. Both are normal. - Idle timeouts. wrpc's own app-level heartbeat is 30 s by default; keep the proxy's idle timeout above it or the proxy will close connections the client believes are healthy.
- Upgrade headers. The proxy has to forward
Upgrade/Connectionfor WebSockets. This is the single most common "it works locally" failure. - Buffering. SSE needs the proxy's response buffering off for
{basePath}/events, or frames arrive in clumps.
Sticky routing
Two things live on the instance that created them, and both need affinity when you run more than one:
- SSE channels. A misrouted request answers
409; the built-in transport recovers by starting a fresh channel, so the failure mode is reconnect churn rather than breakage — but route on the session cookie and it will not happen. - Event logs.
createEventLog()is per-process memory. Its ids are epoch-stamped so a client resuming against a different instance presents a foreign epoch,since()answersnull, and the handler sends a snapshot — visibly degraded instead of silently lossy.
Plain WebSocket calls, events, rooms and subscriptions need no affinity once a backplane is configured.
Backpressure and buffering
The engine limits decide when a slow peer stops being your problem:
| Option | What it does |
|---|---|
maxBackpressure | Outbound bytes buffered for one socket before it is dropped. |
maxBuffer | Inbound assembly buffer per connection. |
fragmentThreshold | Above this, an outbound message is sent fragmented. |
maxPayload | Largest inbound message (16 MiB default). |
For streams, honour write()'s return value and 'drain' — that is what carries backpressure all the way into TCP; see Binary streams.
A deploy checklist
- [ ]
cors.originsset,credentials: trueif sessions are used - [ ] Session store is not
MemorySessionStore - [ ]
SIGTERMcallsclose({ drain }); the backplane is closed after - [ ]
instanceIdcomes from the orchestrator - [ ] Readiness probe hits the bound port;
server.address()is how it is read - [ ] Proxy forwards upgrades, idle timeout > heartbeat, SSE buffering off
- [ ] Sticky routing on the session cookie if SSE or event logs are used
- [ ] A logger is injected — the default writes to
console - [ ]
introspectiondecided; per-connection limits reviewed