WebSockets Realtime skill for Codex

Sockets that survive proxies, deploys and replicas. Codex treats a WebSocket feature as a distributed system rather than an event handler. It authenticates during the HTTP upgrade with an Origin check, sends server heartbeats tuned below proxy idle timeouts, caps buffered output per client, and adds Redis or NATS fan-out so broadcasts reach every replica. Clients get full-jitter reconnects and sequence-based resume, and tests cover auth rejection and two-instance delivery.

  • Backend & APIs
  • $websockets-realtime
  • 245 lines
  • 2 files
  • ~2,700 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- websockets-realtime

Installs to ~/.agents/skills/websockets-realtime/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $websockets-realtime, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds and hardens WebSocket servers and clients - authenticated upgrades, Origin checks, heartbeats, backpressure, message envelopes with sequence numbers, reconnection with jittered backoff, and multi-instance fan-out through Redis or NATS. Covers Node ws, Socket.IO, Python websockets and FastAPI, plus nginx and load-balancer settings. Use when adding live updates, chat, presence or collaborative features, when sockets drop behind a proxy, or when scaling a realtime service past one instance.

Use it when

  • Building chat, presence, collaborative editing or live dashboards with client-to-server traffic
  • Connections drop at regular intervals behind a proxy or load balancer
  • A realtime service must scale beyond a single instance
  • Reviewing WebSocket code for auth, Origin checks and memory safety

Use something else when

How to check the result

Integration tests show a bad Origin or missing auth fails the handshake, a silent client is terminated after one heartbeat, and a message published on one instance reaches a client connected to another.

The mistake it prevents

Broadcasting by looping over the local client set works in development and silently drops messages for users connected to other replicas in production.

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

--- name: websockets-realtime description: Builds and hardens WebSocket servers and clients - authenticated upgrades, Origin checks, heartbeats, backpressure, message envelopes with sequence… license: MIT ---

Done well means: every connection is authenticated before it is accepted, dead peers are detected within a minute, a slow client cannot exhaust server memory, clients reconnect with jitter and resume without losing or duplicating messages, and a broadcast reaches subscribers on every instance, not only the one that received it.

First, confirm WebSockets are the right transport

NeedTransport
Server pushes updates, client rarely sends (feeds, notifications, LLM tokens)Server-Sent Events: use $server-sent-events
Bidirectional, low-latency, frequent client messages (chat, cursors, games)WebSocket
Must work through hostile corporate proxies with fallbacksSocket.IO (WebSocket with HTTP long-polling fallback)
Request/response with typed contractsPlain HTTP or gRPC streaming
Audio/video or unreliable datagramsWebRTC / WebTransport, out of scope here

Inspect before changing anything

  • Read AGENTS.md for the runtime, package manager and test command.
  • Find the current server: rg -n "WebSocketServer|socket\.io|websockets\.serve|@app.websocket|upgrade" --type-add 'web:*.{ts,js,py}' -t web.
  • Find what sits in front: nginx/Caddy config, ALB/Cloud Run/Cloudflare settings, ingress annotations. Most “random disconnects” are proxy idle timeouts, not code.
  • Check how many instances run in production. One instance hides every fan-out bug.

Workflow checklist

- [ ] Authenticate during the HTTP upgrade (cookie + Origin check, or a short-lived ticket)
- [ ] Set maxPayload and per-connection rate limits
- [ ] Define a versioned message envelope with type, id and seq
- [ ] Add server ping/pong heartbeat and terminate dead sockets
- [ ] Respect backpressure (bufferedAmount) and cap per-client queues
- [ ] Add pub/sub fan-out if more than one instance can run
- [ ] Client: reconnect with full-jitter backoff and resume from last seq
- [ ] Graceful shutdown: close with 1012, drain, then exit
- [ ] Proxy config: Upgrade headers, read timeout above heartbeat interval
- [ ] Tests: auth rejection, heartbeat timeout, reconnect + resume, two-instance broadcast

Authentication at the upgrade

Browsers cannot set an Authorization header on new WebSocket(). Pick one:

OptionWhenMust also
Session cookie sent with the upgradeSame-site app already using cookie sessionsCheck Origin against an allowlist (cross-site WebSocket hijacking)
Short-lived ticket in the query stringToken auth, cross-origin clientsTicket single-use, 30-60 s TTL, issued by an authenticated HTTP call; never put the long-lived token in the URL (it lands in logs)
First message carries the tokenLegacy clientsClose with 1008 if no valid auth message within ~5 s; accept nothing else before it

Reject before the handshake completes so unauthenticated sockets never exist:

import { createServer } from "node:http";
import { WebSocketServer, type WebSocket } from "ws";

const server = createServer(app);
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);

server.on("upgrade", async (req, socket, head) => {
  try {
    if (!ALLOWED_ORIGINS.has(req.headers.origin ?? "")) throw new Error("origin");
    const user = await authenticate(req);          // cookie session or one-time ticket
    wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req, user));
  } catch {
    socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
    socket.destroy();
  }
});

Long-lived connections outlive tokens. Store the token expiry on the connection and close with code 4001 (app-defined) when it passes, so the client re-authenticates. Authorize every subscribe/join message against the user, not just the connection.

Message envelope

{ "v": 1, "type": "message.created", "id": "01J9Z...", "seq": 4182, "ts": "2026-03-02T10:15:00Z", "data": { } }
  • id: client-generated for client-to-server messages so the server can dedupe retries.
  • seq: per-channel monotonic number from the server; the client tracks the last one seen.
  • v: protocol version, so old clients get a clear close (4002) instead of silent breakage.
  • Validate every inbound message with a schema (Zod, Pydantic) before acting on it.

Heartbeats and dead-peer detection

TCP does not tell you a phone went into a tunnel. Ping from the server:

const HEARTBEAT_MS = 30_000;
wss.on("connection", (ws: WebSocket & { isAlive?: boolean }) => {
  ws.isAlive = true;
  ws.on("pong", () => { ws.isAlive = true; });
});
const timer = setInterval(() => {
  for (const ws of wss.clients as Set<WebSocket & { isAlive?: boolean }>) {
    if (!ws.isAlive) { ws.terminate(); continue; }
    ws.isAlive = false;
    ws.ping();
  }
}, HEARTBEAT_MS);
wss.on("close", () => clearInterval(timer));

Keep the heartbeat below every idle timeout on the path (nginx proxy_read_timeout default 60 s, AWS ALB idle timeout default 60 s, Cloudflare ~100 s).

Backpressure

ws.send() queues in memory when the client reads slowly. Check before sending:

const MAX_BUFFER = 1 * 1024 * 1024;
function safeSend(ws: WebSocket, payload: string) {
  if (ws.bufferedAmount > MAX_BUFFER) {
    ws.close(1013, "client too slow");   // or drop non-critical messages / coalesce state
    return;
  }
  ws.send(payload);
}

For high-frequency state (cursors, prices) send the latest snapshot on a tick instead of every change.

Scaling past one instance

  • A WebSocket is one long TCP connection, so it does not need sticky sessions. Socket.IO does, because its handshake starts with HTTP polling, unless you force transports: ["websocket"].
  • Fan-out: publish to a broker, and every instance delivers to its local subscribers. Redis pub/sub (or the @socket.io/redis-adapter), NATS, or Postgres LISTEN/NOTIFY for low volume. Redis pub/sub is fire-and-forget: anything published while an instance is reconnecting is lost, which is why clients resume from seq.
  • For resume, keep a bounded per-channel history (Redis Streams with MAXLEN, or a DB table) so resume(channel, lastSeq) can replay the gap; if the gap is too old, tell the client to refetch full state over HTTP.
  • Budget connections per instance (file descriptors, memory ~ tens of KB each); raise ulimit -n and load-test before launch.

Client reconnection

function backoff(attempt: number) {
  const cap = 30_000, base = 500;
  return Math.random() * Math.min(cap, base * 2 ** attempt);   // full jitter
}
  • Reset attempt only after the connection has stayed open ~10 s, or a server that accepts then crashes causes a tight reconnect loop.
  • On open: re-authenticate, re-subscribe, send resume with the last seq per channel.
  • Do not reconnect on 1008 (policy) or 4001 (auth expired) without refreshing credentials first; do reconnect on 1001, 1006, 1011, 1012, 1013.
  • Queue outbound messages while disconnected only if they are idempotent (carry id).

Close codes that matter

CodeMeaningClient reaction
1000Normal closureDo not reconnect unless the app wants to
1001Going away (page or server leaving)Reconnect with backoff
1006Abnormal, no close frame (never sent explicitly)Reconnect with backoff
1008Policy violation (auth, Origin)Stop; surface an error
1009Message too bigFix the client, do not retry the same payload
1011Server errorReconnect with backoff
1012Service restartReconnect with extra jitter
1013Try again later / overloadedReconnect with longer backoff
4000-4999Application-definedDocument each one

Graceful shutdown and deploys

On SIGTERM: stop accepting upgrades, close each socket with 1012, wait a few seconds for in-flight writes, then exit. Clients spread reconnects with jitter, so a rolling deploy does not stampede the new instances. Set the orchestrator’s termination grace period longer than the drain time.

Proxy configuration

location /ws/ {
  proxy_pass http://app;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_set_header Host $host;
  proxy_read_timeout 75s;     # above the 30 s heartbeat
  proxy_send_timeout 75s;
}

Other stacks

  • Python websockets (v14+): use the websockets.asyncio.server.serve API; it sends pings by default (ping_interval=20, ping_timeout=20) and has max_size for payloads.
  • FastAPI/Starlette: @app.websocket("/ws"), authenticate from websocket.cookies or query params before await websocket.accept(); close with code=1008 otherwise.
  • Cloudflare Durable Objects: use the WebSocket Hibernation API; see $cloudflare-durable-objects.

Verification

  • Unit/integration: start the server on an ephemeral port and connect a real client (Node 22+ ships a global WebSocket; Python websockets.connect). Assert: missing or bad auth fails the handshake, a foreign Origin is rejected, oversize messages close with 1009, a client that stops answering pings is terminated.
  • Two-instance test: run two server processes against one Redis, connect a client to each, publish via one, assert delivery on both.
  • Resume test: disconnect mid-stream, publish N messages, reconnect with lastSeq, assert exactly N messages arrive in order with no duplicates.
  • Manual: websocat wss://host/ws (install may need network approval in the sandbox).

Gotchas

  • ws emits error on the socket; an unhandled error event crashes the Node process. Attach ws.on("error", ...) to every connection.
  • Logging full upgrade URLs leaks query-string tickets. Redact before logging.
  • JSON.parse on untrusted input throws; wrap it and close with 1003 or 1008 on garbage.
  • Broadcasting with for (client of wss.clients) on one instance passes tests and fails in production with three replicas.
  • Serverless platforms without WebSocket support (most function runtimes) kill the connection at the request timeout. Use a managed gateway or a long-running service.

Output template

## Realtime change: <feature>
- Transport: WebSocket (why not SSE: ...)
- Auth: <cookie+Origin | ticket>, expiry handling: <...>
- Envelope: v<N>, types added: <...>
- Heartbeat: <interval>; proxy timeouts verified: <nginx/ALB values>
- Fan-out: <Redis pub/sub | NATS | none (single instance, documented)>
- Resume: <history store, retention>
- Tests added: <list>
- Not verified: <e.g. load test, real mobile network>

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "WebSockets Realtime"
  short_description: "Sockets that survive proxies, deploys and replicas"
  default_prompt: "$websockets-realtime Add authenticated WebSocket live updates to our Express app and make it work with three replicas"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem