WebSocket servers behave differently from request/response APIs in ways that matter for deployment. Connections are long-lived, they consume a slot for their entire lifetime, idle connections can be silently dropped by intermediaries, and broadcasting across multiple instances requires shared state. This guide is framework-agnostic (examples in Node with the ws library) and focuses on the production concerns that bite people.
A baseline server
import { WebSocketServer } from 'ws';
const port = process.env.PORT || 8080;
const wss = new WebSocketServer({ port, host: '0.0.0.0' });
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (data) => {
// broadcast to everyone
wss.clients.forEach((c) => {
if (c.readyState === c.OPEN) c.send(data);
});
});
});Binding to 0.0.0.0 is required in a container. That broadcast loop works for a single instance, scaling it correctly is the harder part, covered below.
Heartbeats and idle timeouts
The most common production bug: connections that look open but are actually dead. Networks, proxies, and load balancers drop idle TCP connections, sometimes silently. The fix is a heartbeat, periodic ping/pong to detect and clean up dead connections:
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => clearInterval(interval));Also be aware of ingress idle timeouts: a connection with no traffic for too long may be cut by the proxy. Heartbeats keep the connection active and detect breakage from both ends.
Ingress and the upgrade handshake
A WebSocket connection starts as an HTTP request with an Upgrade: websocket header; the server responds 101 Switching Protocols. Your ingress must allow and forward that upgrade. Kong ingress supports WebSocket upgrades, so connections pass through transparently with TLS terminated at the edge (wss:// from the client). Confirm your client uses wss:// against the HTTPS domain so the connection is encrypted end to edge.
Scaling horizontally: the backplane problem
Here's the core distributed-systems challenge. If client A connects to instance 1 and client B connects to instance 2, a message from A won't reach B, because instance 1 has no idea instance 2's clients exist. The in-memory wss.clients set is per-instance.
The solution is a pub/sub backplane, typically Redis. Each instance subscribes to a channel; when it receives a message to broadcast, it publishes to Redis, and every instance (including itself) receives it and fans out to its own local clients:
import { createClient } from 'redis';
const pub = createClient({ url: process.env.REDIS_URL });
const sub = pub.duplicate();
await pub.connect(); await sub.connect();
await sub.subscribe('broadcast', (msg) => {
wss.clients.forEach((c) => c.readyState === c.OPEN && c.send(msg));
});
// on incoming message from a client:
function broadcast(msg) { pub.publish('broadcast', msg); }This pattern is what makes horizontal scaling actually work for WebSocket servers.
Graceful shutdown
During a deploy, close connections cleanly so clients can reconnect to the new instance rather than seeing abrupt resets:
process.on('SIGTERM', () => {
wss.clients.forEach((c) => c.close(1001, 'Server shutting down'));
wss.close(() => process.exit(0));
});Close code 1001 ("going away") tells clients this is expected and they should reconnect.
Deploying on PandaStack
- 1Provision a managed Redis instance for the pub/sub backplane. Reference via
REDIS_URL. - 2Connect your repo as a container app. Build runs in an ephemeral Job pod with rootless BuildKit and deploys via Helm behind Kong ingress (WebSocket upgrades supported), with automatic SSL so clients use
wss://. - 3Set
REDIS_URLto activate the backplane before scaling to more than one replica. - 4Tail live logs to watch connections, pings, and the 101 handshakes.
| Concern | Production answer |
|---|---|
| Dead connections | 30s ping/pong heartbeat |
| Idle timeout | Heartbeats keep alive |
| Multi-instance broadcast | Redis pub/sub backplane |
| Shutdown | Close with code 1001 |
| Transport security | wss:// via auto SSL |
Scale-to-zero caution
Scale-to-zero and persistent connections conflict: scaling to zero drops every connection. For a server with continuously connected clients, keep a warm instance on a paid tier. The free tier suits low-traffic or intermittently used realtime features where clients reconnect gracefully (your client should implement reconnect-with-backoff regardless).
Connection limits and memory
Each connection consumes memory and a file descriptor. At high concurrency, choose a memory-optimized tier and track memory in the server-side metrics. If you're holding per-connection state (rooms, user data), that scales with connection count, so plan capacity accordingly.
Common pitfalls
- No heartbeat, dead connections accumulate and broadcasts hit zombies.
- No backplane, broadcasts don't cross instances after you scale.
ws://instead ofwss://, unencrypted and often blocked by browsers on HTTPS pages.- Scale-to-zero on a persistent-connection server, mass disconnects on idle.
References
- WebSocket protocol (RFC 6455): https://datatracker.ietf.org/doc/html/rfc6455
- ws (Node WebSocket library): https://github.com/websockets/ws
- Redis pub/sub: https://redis.io/docs/latest/develop/interact/pubsub/
- WebSocket close codes (IANA): https://www.iana.org/assignments/websocket/websocket.xml
- MDN WebSocket API: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
A production WebSocket server lives or dies by heartbeats, a pub/sub backplane for scaling, and graceful shutdown. Deploy yours with managed Redis and automatic wss:// on PandaStack's free tier: https://dashboard.pandastack.io