Realtime Communication
When to use
- Push events from server to clients without polling (live feeds, notifications)
- Bidirectional communication (chat, collaborative tools, multiplayer)
- Presence tracking (who is online, typing indicators, live cursors)
- Live dashboards with sub-second data refresh
- Peer-to-peer media (WebRTC audio/video, data channels)
- Scaling a WebSocket server beyond a single process
- Choosing between WebSocket, SSE, and managed services
Workflow
- Classify communication pattern — Unidirectional server-to-client (SSE), bidirectional (WebSocket), peer-to-peer media (WebRTC), or all three?
- Select transport — Use decision matrix in Standards.
- Design channel/room model — Namespace → room → subscriber. Define channel naming convention (
{entity}:{id}, e.g. document:abc123).
- Design message envelope —
{ event, data, id, timestamp }. Unique id per event enables client-side deduplication and Last-Event-ID replay (SSE).
- Implement server — Choose framework/platform. Implement auth middleware (validate JWT before upgrading connection). Emit events to channel on DB/queue change.
- Implement client — Reconnection with exponential backoff (base 1 s, max 30 s, jitter ±20%). Heartbeat/ping-pong detection (detect silent drops). Reconcile missed events on reconnect using last received event ID or cursor.
- Presence — Server maintains ephemeral presence store (Redis SETEX with TTL; refresh on heartbeat). Broadcast enter/leave diffs, not full roster, to avoid fan-out storms at scale.
- Scale horizontally — WebSocket servers are stateful. Use Redis Pub/Sub (or Redis Streams) as message bus between nodes. Or use managed service to offload fan-out.
- Backpressure — Per-connection send buffer limit. Slow client detection: if buffer exceeds threshold, drop or disconnect; log and alert. Never block event loop waiting for slow client.
- Observability — Track: connected clients (gauge), messages sent/received (counters), connection duration histogram, reconnect rate, per-room subscriber count. Alert on reconnect rate spike (network instability) or message queue depth.
- Security — Auth on upgrade (not on first message). Rate-limit per connection (token bucket). Validate channel names server-side before allowing subscription. Sanitize all inbound data. TLS/WSS mandatory.
- Test — Unit-test event handlers with mock socket. Load-test with
k6 WebSocket API (k6/ws) or artillery (@artillery/plugin-expect). Chaos: abrupt disconnect, slow consumer, duplicate delivery.
Standards
Transport decision matrix
| Pattern |
Best choice |
Avoid |
| Server → client one-way (feeds, notifications) |
SSE (native browser EventSource, automatic reconnect) |
WebSocket (bidirectional overhead) |
| Bidirectional, low-latency (<100 ms) |
WebSocket (RFC 6455) |
SSE, long-polling |
| Bidirectional, batteries-included |
Socket.IO 4.x (WS + HTTP fallback + rooms) |
Raw WS when rooms/namespaces needed |
| Peer-to-peer media (audio/video/data) |
WebRTC + signaling server |
WebSocket (too high latency for media) |
| Fully managed, global edge, presence built-in |
Ably or Pusher Channels |
Self-hosted at early stage |
| Postgres-native, Supabase project |
Supabase Realtime (Phoenix Channels) |
External broker |
| GraphQL API |
GraphQL subscriptions over WS (graphql-ws library) |
Polling |
| Mobile push (not in-app) |
FCM / APNs via server-side SDK |
WebSocket (background-killed) |
Examples shown in JS/Node; the same patterns apply across platforms — equivalents (mobile, Go, Python, etc.) follow the Standards table.
WebSocket server (Node.js, ws library v8+)
import { WebSocketServer, WebSocket } from 'ws';
import { createClient } from 'redis';
const wss = new WebSocketServer({ port: 8080 });
const pub = createClient(); // publish
const sub = pub.duplicate(); // subscribe
await sub.connect();
await pub.connect();
// Subscribe to Redis channel for fan-out across nodes
await sub.subscribe('events', (message) => {
const payload = JSON.parse(message);
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN && client.room === payload.room) {
client.send(message);
}
});
});
wss.on('connection', (ws, req) => {
const token = authenticate(req); // validate JWT; close if invalid
if (!token) { ws.close(1008, 'Unauthorized'); return; }
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (raw) => handleMessage(ws, raw));
});
// Heartbeat every 30s — detect silent disconnects
const hb = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) { ws.terminate(); return; }
ws.isAlive = false;
ws.ping();
});
}, 30_000);
wss.on('close', () => clearInterval(hb));
SSE (Server-Sent Events)
// Express SSE endpoint
app.get('/events', (req, res) => {
const token = verifyBearer(req.headers.authorization);
if (!token) { res.status(401).end(); return; }
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering
res.flushHeaders();
const lastId = req.headers['last-event-id'];
// Replay missed events since lastId from DB/cache here
const sendEvent = (data: object, id: string) =>
res.write(`id: ${id}\ndata: ${JSON.stringify(data)}\n\n`);
const unsub = emitter.on('event', sendEvent);
req.on('close', () => unsub());
});
- Client-side: native
EventSource auto-reconnects using Last-Event-ID.
- SSE is HTTP/1.1; browser limit of 6 connections per domain — use HTTP/2 to remove limit.
- SSE does NOT work with HTTP/1.1 behind proxies that buffer responses; set
X-Accel-Buffering: no.
Socket.IO 4.x patterns
- Use namespaces for top-level separation (e.g.
/chat, /notifications).
- Use rooms within a namespace for per-entity channels.
- Adapter:
@socket.io/redis-adapter for multi-node with Redis Pub/Sub.
- Sticky sessions required only when using HTTP long-polling fallback (set
transport: ['websocket'] to skip).
socket.io-msgpack-parser for binary efficiency on high-throughput channels.
Managed services
Ably
- Channels:
ably.channels.get('room:id') — attach, publish, subscribe.
- Presence:
channel.presence.enter(clientData), channel.presence.get().
- History:
channel.history({ limit: 100 }) for catch-up on reconnect.
- Push notifications: Ably Push (FCM/APNs gateway) from same API.
Pusher Channels
- Public / private (requires auth endpoint) / presence (auth + member data).
- Server SDK:
pusher.trigger('channel', 'event', data).
- Client:
pusher.subscribe('channel').bind('event', handler).
- Limit: 100 concurrent connections on free tier; 10k on paid.
Supabase Realtime
const channel = supabase
.channel('room:abc')
.on('postgres_changes', { event: '*', schema: 'public', table: 'messages' },
(payload) => console.log(payload))
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
})
.subscribe();
- Postgres Changes: row-level CDC pushed to clients (filter by
filter: 'room_id=eq.abc').
- Row-Level Security must be enabled; Realtime respects RLS policies.
- Presence uses in-memory CRDT; not persisted across restarts.
Presence design
- Ephemeral store: Redis
HSET presence:{room} {userId} {json} + EXPIRE TTL (30–60 s).
- Heartbeat: client sends ping every 15 s; server resets TTL on receive.
- Fan-out events:
presence.join / presence.leave diffs only — never broadcast full roster on every heartbeat.
- Large rooms (>10k members): do not track individual presence; use counters instead.
WebRTC signaling
- Signaling (SDP offer/answer + ICE candidates) via WebSocket or Ably/Pusher.
- STUN: use Google's
stun:stun.l.google.com:19302 for dev; deploy own STUN for prod (coturn).
- TURN: mandatory for corporate firewalls; deploy coturn or use Twilio TURN (BYOC pricing).
- ICE restart: handle
iceConnectionState === 'failed' with restartIce().
Client reconnection pattern
function connect(url: string, backoff = 1000) {
const ws = new WebSocket(url);
ws.addEventListener('open', () => { backoff = 1000; });
ws.addEventListener('close', () => {
const jitter = Math.random() * 0.4 * backoff;
setTimeout(() => connect(url, Math.min(backoff * 2, 30_000)), backoff + jitter);
});
ws.addEventListener('message', handleMessage);
return ws;
}
- On reconnect, send
{ lastEventId } in first message or as query param to get missed events.
- Never reconnect faster than 1 s to avoid thundering herd.
Scaling fan-out
| Subscribers |
Architecture |
| <1 000 |
Single process; in-memory event emitter |
| 1 000–100 000 |
Multiple WS nodes + Redis Pub/Sub adapter |
| >100 000 |
Managed service (Ably / Pusher / Fanout.io) or custom Elixir/Phoenix (Phoenix PubSub scales to millions) |
- Redis Pub/Sub delivers to all nodes; each node filters to connected clients.
- For very high fan-out (same event → millions): use Kafka + dedicated push service tier.
Common mistakes to avoid
- No heartbeat — TCP connections silently drop through NAT/proxies; without ping-pong, zombie connections accumulate and events are lost.
- Reconnecting immediately without backoff — thundering herd crushes server on restart.
- Broadcasting full presence roster on every heartbeat — O(n²) fan-out; at 1k users this becomes 1M messages/heartbeat.
- No auth on WebSocket upgrade — only token in URL query param (logged in access logs); use
Authorization header in upgrade HTTP request or first message validation.
- Buffering response in nginx — SSE and WebSocket streams are broken by default proxy buffering; always set
proxy_buffering off and proxy_read_timeout 3600s.
- Single WebSocket server without Redis adapter — scale-out fails; messages from Node A never reach clients connected to Node B.
- Sending JSON as string inside JSON — double-serialization; always send
JSON.stringify(payload) directly.
- Not handling
ws.readyState before send — crashes on CLOSING/CLOSED socket.
- Storing ephemeral presence in primary DB — high write amplification; use Redis with TTL.
Output format
Produce artifacts in docs/realtime/ using .claude/templates/architecture.md adapted for realtime:
transport-decision.md — chosen transport(s) with rationale
channel-schema.md — channel naming, event taxonomy, message envelope spec
presence-design.md — presence store, heartbeat interval, fan-out strategy
scaling-plan.md — concurrent connection estimate, Redis adapter or managed service config
- Code snippets (server + client) inline as fenced code blocks matching project language
Related checklists
.claude/checklists/architecture.md
.claude/checklists/performance.md
.claude/checklists/security.md
.claude/checklists/backend.md
Related agents
.claude/agents/engineering/realtime-engineer.md
.claude/agents/engineering/backend-engineer.md
.claude/agents/engineering/frontend-engineer.md
.claude/agents/engineering/mobile-engineer.md
.claude/agents/quality/performance-engineer.md
.claude/agents/quality/reliability-engineer.md
.claude/agents/core/solution-architect.md
1---2name: realtime3description: Use for real-time/live-update/push design. Triggers — WebSocket, SSE, WebRTC, presence, live cursors, chat, collaborative editing, Socket.IO, Ably, Pusher, Supabase Realtime.4---56# Realtime Communication78## When to use9- Push events from server to clients without polling (live feeds, notifications)10- Bidirectional communication (chat, collaborative tools, multiplayer)11- Presence tracking (who is online, typing indicators, live cursors)12- Live dashboards with sub-second data refresh13- Peer-to-peer media (WebRTC audio/video, data channels)14- Scaling a WebSocket server beyond a single process15- Choosing between WebSocket, SSE, and managed services1617## Workflow18191. **Classify communication pattern** — Unidirectional server-to-client (SSE), bidirectional (WebSocket), peer-to-peer media (WebRTC), or all three?202. **Select transport** — Use decision matrix in Standards.213. **Design channel/room model** — Namespace → room → subscriber. Define channel naming convention (`{entity}:{id}`, e.g. `document:abc123`).224. **Design message envelope** — `{ event, data, id, timestamp }`. Unique `id` per event enables client-side deduplication and `Last-Event-ID` replay (SSE).235. **Implement server** — Choose framework/platform. Implement auth middleware (validate JWT before upgrading connection). Emit events to channel on DB/queue change.246. **Implement client** — Reconnection with exponential backoff (base 1 s, max 30 s, jitter ±20%). Heartbeat/ping-pong detection (detect silent drops). Reconcile missed events on reconnect using last received event ID or cursor.257. **Presence** — Server maintains ephemeral presence store (Redis SETEX with TTL; refresh on heartbeat). Broadcast enter/leave diffs, not full roster, to avoid fan-out storms at scale.268. **Scale horizontally** — WebSocket servers are stateful. Use Redis Pub/Sub (or Redis Streams) as message bus between nodes. Or use managed service to offload fan-out.279. **Backpressure** — Per-connection send buffer limit. Slow client detection: if buffer exceeds threshold, drop or disconnect; log and alert. Never block event loop waiting for slow client.2810. **Observability** — Track: connected clients (gauge), messages sent/received (counters), connection duration histogram, reconnect rate, per-room subscriber count. Alert on reconnect rate spike (network instability) or message queue depth.2911. **Security** — Auth on upgrade (not on first message). Rate-limit per connection (token bucket). Validate channel names server-side before allowing subscription. Sanitize all inbound data. TLS/WSS mandatory.3012. **Test** — Unit-test event handlers with mock socket. Load-test with `k6` WebSocket API (`k6/ws`) or `artillery` (`@artillery/plugin-expect`). Chaos: abrupt disconnect, slow consumer, duplicate delivery.3132## Standards3334### Transport decision matrix3536| Pattern | Best choice | Avoid |37|---|---|---|38| Server → client one-way (feeds, notifications) | **SSE** (native browser EventSource, automatic reconnect) | WebSocket (bidirectional overhead) |39| Bidirectional, low-latency (<100 ms) | **WebSocket** (RFC 6455) | SSE, long-polling |40| Bidirectional, batteries-included | **Socket.IO 4.x** (WS + HTTP fallback + rooms) | Raw WS when rooms/namespaces needed |41| Peer-to-peer media (audio/video/data) | **WebRTC** + signaling server | WebSocket (too high latency for media) |42| Fully managed, global edge, presence built-in | **Ably** or **Pusher Channels** | Self-hosted at early stage |43| Postgres-native, Supabase project | **Supabase Realtime** (Phoenix Channels) | External broker |44| GraphQL API | **GraphQL subscriptions** over WS (`graphql-ws` library) | Polling |45| Mobile push (not in-app) | **FCM** / **APNs** via server-side SDK | WebSocket (background-killed) |4647Examples shown in JS/Node; the same patterns apply across platforms — equivalents (mobile, Go, Python, etc.) follow the Standards table.4849### WebSocket server (Node.js, `ws` library v8+)50```typescript51import { WebSocketServer, WebSocket } from 'ws';52import { createClient } from 'redis';5354const wss = new WebSocketServer({ port: 8080 });55const pub = createClient(); // publish56const sub = pub.duplicate(); // subscribe5758await sub.connect();59await pub.connect();6061// Subscribe to Redis channel for fan-out across nodes62await sub.subscribe('events', (message) => {63 const payload = JSON.parse(message);64 wss.clients.forEach((client) => {65 if (client.readyState === WebSocket.OPEN && client.room === payload.room) {66 client.send(message);67 }68 });69});7071wss.on('connection', (ws, req) => {72 const token = authenticate(req); // validate JWT; close if invalid73 if (!token) { ws.close(1008, 'Unauthorized'); return; }7475 ws.isAlive = true;76 ws.on('pong', () => { ws.isAlive = true; });77 ws.on('message', (raw) => handleMessage(ws, raw));78});7980// Heartbeat every 30s — detect silent disconnects81const hb = setInterval(() => {82 wss.clients.forEach((ws) => {83 if (!ws.isAlive) { ws.terminate(); return; }84 ws.isAlive = false;85 ws.ping();86 });87}, 30_000);88wss.on('close', () => clearInterval(hb));89```9091### SSE (Server-Sent Events)92```typescript93// Express SSE endpoint94app.get('/events', (req, res) => {95 const token = verifyBearer(req.headers.authorization);96 if (!token) { res.status(401).end(); return; }9798 res.setHeader('Content-Type', 'text/event-stream');99 res.setHeader('Cache-Control', 'no-cache');100 res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering101 res.flushHeaders();102103 const lastId = req.headers['last-event-id'];104 // Replay missed events since lastId from DB/cache here105106 const sendEvent = (data: object, id: string) =>107 res.write(`id: ${id}\ndata: ${JSON.stringify(data)}\n\n`);108109 const unsub = emitter.on('event', sendEvent);110 req.on('close', () => unsub());111});112```113- Client-side: native `EventSource` auto-reconnects using `Last-Event-ID`.114- SSE is HTTP/1.1; browser limit of 6 connections per domain — use HTTP/2 to remove limit.115- SSE does NOT work with HTTP/1.1 behind proxies that buffer responses; set `X-Accel-Buffering: no`.116117### Socket.IO 4.x patterns118- Use namespaces for top-level separation (e.g. `/chat`, `/notifications`).119- Use rooms within a namespace for per-entity channels.120- Adapter: `@socket.io/redis-adapter` for multi-node with Redis Pub/Sub.121- Sticky sessions required only when using HTTP long-polling fallback (set `transport: ['websocket']` to skip).122- `socket.io-msgpack-parser` for binary efficiency on high-throughput channels.123124### Managed services125126**Ably**127- Channels: `ably.channels.get('room:id')` — attach, publish, subscribe.128- Presence: `channel.presence.enter(clientData)`, `channel.presence.get()`.129- History: `channel.history({ limit: 100 })` for catch-up on reconnect.130- Push notifications: Ably Push (FCM/APNs gateway) from same API.131132**Pusher Channels**133- Public / private (requires auth endpoint) / presence (auth + member data).134- Server SDK: `pusher.trigger('channel', 'event', data)`.135- Client: `pusher.subscribe('channel').bind('event', handler)`.136- Limit: 100 concurrent connections on free tier; 10k on paid.137138**Supabase Realtime**139```typescript140const channel = supabase141 .channel('room:abc')142 .on('postgres_changes', { event: '*', schema: 'public', table: 'messages' },143 (payload) => console.log(payload))144 .on('presence', { event: 'sync' }, () => {145 const state = channel.presenceState();146 })147 .subscribe();148```149- Postgres Changes: row-level CDC pushed to clients (filter by `filter: 'room_id=eq.abc'`).150- Row-Level Security must be enabled; Realtime respects RLS policies.151- Presence uses in-memory CRDT; not persisted across restarts.152153### Presence design154- **Ephemeral store**: Redis `HSET presence:{room} {userId} {json}` + `EXPIRE` TTL (30–60 s).155- **Heartbeat**: client sends ping every 15 s; server resets TTL on receive.156- **Fan-out events**: `presence.join` / `presence.leave` diffs only — never broadcast full roster on every heartbeat.157- **Large rooms** (>10k members): do not track individual presence; use counters instead.158159### WebRTC signaling160- Signaling (SDP offer/answer + ICE candidates) via WebSocket or Ably/Pusher.161- STUN: use Google's `stun:stun.l.google.com:19302` for dev; deploy own STUN for prod (coturn).162- TURN: mandatory for corporate firewalls; deploy coturn or use Twilio TURN (BYOC pricing).163- ICE restart: handle `iceConnectionState === 'failed'` with `restartIce()`.164165### Client reconnection pattern166```typescript167function connect(url: string, backoff = 1000) {168 const ws = new WebSocket(url);169 ws.addEventListener('open', () => { backoff = 1000; });170 ws.addEventListener('close', () => {171 const jitter = Math.random() * 0.4 * backoff;172 setTimeout(() => connect(url, Math.min(backoff * 2, 30_000)), backoff + jitter);173 });174 ws.addEventListener('message', handleMessage);175 return ws;176}177```178- On reconnect, send `{ lastEventId }` in first message or as query param to get missed events.179- Never reconnect faster than 1 s to avoid thundering herd.180181### Scaling fan-out182| Subscribers | Architecture |183|---|---|184| <1 000 | Single process; in-memory event emitter |185| 1 000–100 000 | Multiple WS nodes + Redis Pub/Sub adapter |186| >100 000 | Managed service (Ably / Pusher / Fanout.io) or custom Elixir/Phoenix (Phoenix PubSub scales to millions) |187188- Redis Pub/Sub delivers to all nodes; each node filters to connected clients.189- For very high fan-out (same event → millions): use Kafka + dedicated push service tier.190191## Common mistakes to avoid192193- **No heartbeat** — TCP connections silently drop through NAT/proxies; without ping-pong, zombie connections accumulate and events are lost.194- **Reconnecting immediately without backoff** — thundering herd crushes server on restart.195- **Broadcasting full presence roster on every heartbeat** — O(n²) fan-out; at 1k users this becomes 1M messages/heartbeat.196- **No auth on WebSocket upgrade** — only token in URL query param (logged in access logs); use `Authorization` header in upgrade HTTP request or first message validation.197- **Buffering response in nginx** — SSE and WebSocket streams are broken by default proxy buffering; always set `proxy_buffering off` and `proxy_read_timeout 3600s`.198- **Single WebSocket server without Redis adapter** — scale-out fails; messages from Node A never reach clients connected to Node B.199- **Sending JSON as string inside JSON** — double-serialization; always send `JSON.stringify(payload)` directly.200- **Not handling `ws.readyState` before send** — crashes on `CLOSING`/`CLOSED` socket.201- **Storing ephemeral presence in primary DB** — high write amplification; use Redis with TTL.202203## Output format204205Produce artifacts in `docs/realtime/` using `.claude/templates/architecture.md` adapted for realtime:206- `transport-decision.md` — chosen transport(s) with rationale207- `channel-schema.md` — channel naming, event taxonomy, message envelope spec208- `presence-design.md` — presence store, heartbeat interval, fan-out strategy209- `scaling-plan.md` — concurrent connection estimate, Redis adapter or managed service config210- Code snippets (server + client) inline as fenced code blocks matching project language211212## Related checklists213- `.claude/checklists/architecture.md`214- `.claude/checklists/performance.md`215- `.claude/checklists/security.md`216- `.claude/checklists/backend.md`217218## Related agents219- `.claude/agents/engineering/realtime-engineer.md`220- `.claude/agents/engineering/backend-engineer.md`221- `.claude/agents/engineering/frontend-engineer.md`222- `.claude/agents/engineering/mobile-engineer.md`223- `.claude/agents/quality/performance-engineer.md`224- `.claude/agents/quality/reliability-engineer.md`225- `.claude/agents/core/solution-architect.md`