Backend & APIs No. 095

Realtime WebSockets and SSE skill for Claude Code

Live connections that scale out and recover on their own. Claude picks SSE or WebSockets from the actual traffic pattern, authenticates the handshake with cookies plus origin checks or short-lived tickets, and adds heartbeats tuned below every proxy idle timeout. Slow clients get disconnected before they exhaust memory, events fan out through a broker so any instance can deliver them, and clients reconnect with jitter and resume from a sequence number.

  • namerealtime-websockets
  • size168 lines · ≈2,000 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds realtime features over WebSockets and Server-Sent Events - choosing WS vs SSE vs polling, handshake authentication and origin checks, message envelopes, heartbeats, backpressure for slow clients, horizontal scaling with Redis or NATS pub/sub, reconnection with backoff and resume, proxy and load balancer timeouts, and draining on deploy. Use when adding live updates, chat, notifications, presence or collaborative features, or when sockets drop, leak memory, or stop working behind a proxy or after scaling out.

Try asking

  • Add live notifications to our app. Should we use SSE or WebSockets?
  • Our sockets disconnect every 60 seconds behind nginx
  • Messages only reach users on the same server since we added a second instance

Use it when

  • Adding chat, notifications, presence, live dashboards or collaboration
  • Connections drop, leak or fail behind a proxy or load balancer
  • Scaling a socket server from one instance to many
  • Designing reconnect and catch-up behavior for clients

Skip it when

How to check the result

A message published on one instance reaches a client on another, a dropped client reconnects and receives missed events, and a paused reader is closed while server memory stays flat.

The common mistake it prevents

Skipping server heartbeats, so proxies silently cut idle sockets and dead connections accumulate on the server for hours.

~/.claude/skills/realtime-websockets/SKILL.md

--- name: realtime-websockets description: Builds realtime features over WebSockets and Server-Sent Events - choosing WS vs SSE vs polling, handshake authentication and origin… license: MIT ---

A realtime system is done when connections are authenticated at the handshake, dead peers are detected within a minute, slow consumers cannot exhaust server memory, any server instance can deliver any message, and clients reconnect and catch up without user action or a thundering herd.

Workflow

- [ ] 1. Pick the transport from the traffic pattern
- [ ] 2. Define the message protocol (envelope, types, versioning, sequence numbers)
- [ ] 3. Authenticate the handshake; authorize every subscription
- [ ] 4. Heartbeats and idle timeouts across every hop
- [ ] 5. Backpressure and per-connection limits
- [ ] 6. Fan-out across instances (pub/sub) and connection state storage
- [ ] 7. Client reconnection with backoff, jitter and resume
- [ ] 8. Deploy draining, metrics, load test

1. Transport choice

PatternUse
Server pushes, client rarely sends (feeds, notifications, progress, LLM token streams)SSE (text/event-stream): plain HTTP, auto-reconnect, Last-Event-ID resume
Bidirectional, low-latency, frequent client messages (chat, games, collaboration, presence)WebSocket
Updates every 30 s+ or very constrained infraPolling with ETags
Browser-to-browser media or dataWebRTC (signaling over WS)
Managed option without running socketsPusher/Ably/Supabase Realtime/Cloudflare Durable Objects

SSE over HTTP/1.1 is limited to about 6 connections per origin per browser; serve over HTTP/2 or multiplex streams into one connection. Socket.IO is a protocol on top of WS with its own client; only use it when you want its rooms, acks and fallbacks, and then use its Redis adapter for scaling.

2. Protocol

{ "v": 1, "type": "message.created", "id": "evt_01J...", "seq": 1842, "channel": "room:42", "data": { } }
  • Every server event carries a monotonically increasing seq per channel (or a global event ID) so clients detect gaps and resume.
  • Client commands carry a client-generated id; the server replies with ack or error referencing it. Commands are idempotent by that ID.
  • Validate every inbound message against a schema; cap message size (e.g. 64 KB) with the server’s max payload option.
  • Version the envelope (v); old clients live for weeks in open tabs.

3. Authentication and authorization

  • Browsers cannot set custom headers on new WebSocket(url). Options in order of preference:
    1. Same-site session cookie sent with the handshake, plus a strict Origin header allowlist check on upgrade (prevents cross-site WebSocket hijacking).
    2. Short-lived single-use ticket: client calls POST /realtime/ticket (authenticated), receives a token valid for 30-60 s, connects with wss://.../ws?ticket=...; server redeems and deletes it. Never put long-lived tokens in URLs (they land in logs).
    3. First message {type:"auth", token} with a server timeout of a few seconds before closing.
  • Authorize each subscribe: subscribe room:42 checks membership server-side; channel names from clients are untrusted input.
  • Re-validate on token expiry: close with code 4001 (app-defined) and let the client reauthenticate, or push a refresh request before expiry.

4. Heartbeats and timeouts

Every hop has an idle timeout that silently kills quiet connections: AWS ALB 60 s default (configurable), nginx proxy_read_timeout 60 s default, Cloudflare about 100 s, mobile NATs often less. Send a ping every 20-30 s from the server (WS ping frames, or an SSE comment line : ping\n\n), and terminate connections that miss two pongs. Server-side dead-peer detection prevents leaked connections from piling up after network drops.

Proxy configuration for upgrades and buffering is in references/infra-config.md; read it when deploying behind nginx, a cloud load balancer, or Kubernetes ingress.

5. Backpressure

import { WebSocketServer, WebSocket } from "ws";

const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
const MAX_BUFFERED = 1 * 1024 * 1024;   // 1 MiB queued per client

function send(ws: WebSocket, msg: object) {
  if (ws.readyState !== WebSocket.OPEN) return;
  if (ws.bufferedAmount > MAX_BUFFERED) {
    ws.close(1013, "slow consumer");    // try again later; client resyncs on reconnect
    return;
  }
  ws.send(JSON.stringify(msg));
}

Policies for slow consumers: drop and resync (above), coalesce (send only the latest state per key), or sample. Never let an unbounded per-connection queue grow. Also cap connections per user and per IP, and inbound message rate per connection.

6. Scaling out

  • Each instance holds only its own sockets. Publish events to a broker (Redis pub/sub, Redis Streams, NATS, Postgres LISTEN/NOTIFY for small scale); every instance subscribes and delivers to its local sockets for that channel.
  • Redis pub/sub is fire-and-forget: an instance that blips misses messages. For guaranteed catch-up, persist events (Redis Streams with IDs, or a DB table) and let clients resume from seq.
  • Sticky sessions are not required for raw WS/SSE once fan-out uses a broker; they are required for Socket.IO’s HTTP long-polling fallback.
  • Presence: store per-connection entries with TTL in Redis (refresh on heartbeat), not in instance memory.
  • Capacity: plan memory per connection (tens of KB in Node ws plus buffers), raise file descriptor limits (ulimit -n), and load test before launch.

7. Client reconnection

let attempt = 0;
async function connect() {
  const ws = new WebSocket(`wss://api.example.com/ws?ticket=${await getTicket()}`);
  ws.onopen = () => { attempt = 0; ws.send(JSON.stringify({ type: "resume", lastSeq })); };
  ws.onclose = (e) => {
    if (e.code === 4001) return reauthenticate().then(connect);   // app-defined auth failure
    const delay = Math.min(30_000, 1000 * 2 ** attempt++) * Math.random();   // full jitter
    setTimeout(connect, delay);
  };
}

On resume, the server replays events after lastSeq or tells the client to refetch full state if the gap is too old. Also reconnect on online and visibilitychange events. SSE’s EventSource reconnects automatically and sends Last-Event-ID if events carry id: lines; set the retry: field to control delay.

8. Deploys and observability

  • On SIGTERM: stop accepting upgrades, send close code 1012 (service restart) or 1001 (going away) to clients in batches spread over 10-30 s, then exit. Clients reconnect to new instances with jitter instead of all at once.
  • Metrics: open connections, connects/disconnects per minute by close code, messages in/out per second, send-buffer drops, broker lag, handshake auth failures.

Gotchas

  • ws:// from an https:// page is blocked as mixed content; always wss:// in production.
  • Compression (permessage-deflate) costs memory per connection; leave it off unless messages are large and repetitive.
  • Serverless functions cannot hold WebSockets; use API Gateway WebSocket APIs, Durable Objects, or a dedicated service.
  • Broadcasting by looping over all sockets on every event does not scale; index sockets by channel.
  • Close codes 4000-4999 are for applications; 1006 means abnormal closure (network drop or no close frame), never sent explicitly.

Verification

  1. Integration test: two clients on different server instances; a message published via instance A arrives at a client on instance B.
  2. Kill the network (or the server) mid-session: the client reconnects with backoff and receives missed events via resume.
  3. Simulate a slow consumer (pause reading): server memory stays bounded and the connection closes with 1013.
  4. Leave a connection idle for 5 minutes behind the real proxy: it stays open.
  5. Load test (k6 k6/ws or artillery) to target connection count; record memory per connection.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/infra-config.md 115 lines · 4.4 KB
# Proxy and infrastructure configuration for WS and SSE

Read when deploying sockets behind a reverse proxy, cloud load balancer, CDN, or
Kubernetes ingress. Every hop needs: upgrade support (WS), no response buffering (SSE),
and idle timeouts longer than your heartbeat interval.

## nginx

```nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    location /ws {
        proxy_pass http://app_upstream;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_read_timeout 3600s;     # default 60s kills idle sockets
        proxy_send_timeout 3600s;
    }

    location /events {                # SSE
        proxy_pass http://app_upstream;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_buffering off;          # or send header X-Accel-Buffering: no from the app
        proxy_cache off;
        proxy_read_timeout 3600s;
        gzip off;                     # compression buffers chunks
    }
}
```

Reload with `nginx -t && nginx -s reload`. During reload, old workers keep existing
connections until `worker_shutdown_timeout` (unset means they wait forever); set it to a
few minutes so reloads finish.

## AWS Application Load Balancer

- WebSockets work over HTTP/1.1 listeners without extra config.
- Idle timeout: attribute `idle_timeout.timeout_seconds` (default 60, max 4000). Heartbeat
  below it.
- Target group deregistration delay (default 300 s) controls how long draining instances
  keep connections; align with your SIGTERM drain logic.
- Stickiness is only needed for Socket.IO polling.

## AWS API Gateway WebSocket APIs

- Managed connection handling with `$connect`, `$disconnect`, `$default` routes; push to
  clients with the `@connections` management API using the connection ID.
- Idle timeout 10 minutes, max connection duration 2 hours: clients must reconnect.
- Store connection IDs and subscriptions in DynamoDB; delete on `$disconnect` and on
  `GoneException` (410) when posting.

## Google Cloud Run

- WebSockets and SSE supported; the request timeout (max 60 minutes) caps connection
  life, so clients must reconnect periodically.
- Each socket counts against container concurrency; set `--concurrency` to what one
  instance can actually hold and consider session affinity for Socket.IO.

## Cloudflare (proxied)

- WebSockets supported on all plans; idle connections without traffic close after about
  100 seconds, so heartbeat every 30 s.
- For SSE, avoid caching the endpoint and do not enable features that buffer responses.
- Durable Objects with the WebSocket Hibernation API keep many idle sockets cheaply.

## Kubernetes ingress-nginx

```yaml
metadata:
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-buffering: "off"     # SSE
```

Set `terminationGracePeriodSeconds` above your drain window and add a `preStop` sleep of
a few seconds so endpoints are removed before the process starts closing sockets.

## Node.js `ws` upgrade handling with auth

```ts
server.on("upgrade", async (req, socket, head) => {
  const origin = req.headers.origin;
  if (!ALLOWED_ORIGINS.has(origin ?? "")) { socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); return socket.destroy(); }
  const user = await authenticateUpgrade(req).catch(() => null);  // cookie or ticket
  if (!user) { socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); return socket.destroy(); }
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req, user));
});

// heartbeat
const interval = setInterval(() => {
  for (const ws of wss.clients) {
    if ((ws as any).isAlive === false) { ws.terminate(); continue; }
    (ws as any).isAlive = false;
    ws.ping();
  }
}, 30_000);
wss.on("connection", (ws) => { (ws as any).isAlive = true; ws.on("pong", () => ((ws as any).isAlive = true)); });
wss.on("close", () => clearInterval(interval));
```

## OS limits

- Raise open file limits for the service user (`LimitNOFILE=1048576` in systemd, or the
  container runtime's ulimit setting). Each socket is a file descriptor.
- For very high connection counts, tune ephemeral port range and TCP keepalive on proxies
  that open upstream connections per client.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc