Choosing between Server-Sent Events and WebSockets for real-time client connections, and implementing whichever you pick correctly — SSE wire format per WHATWG, WebSocket frame format per RFC 6455, automatic reconnection with `Last-Event-ID`, ping/pong heartbeats, the HTTP/1.1 6-connection-per-origin limit, and the proxy-buffering trap that silently breaks SSE. Grounded in WHATWG, RFC 6455, MDN. NOT for WebRTC, gRPC streaming, MQTT/IoT brokers, or third-party realtime services (Pusher, Ably).
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Une commande directe contourne le prompt de vérification. Examinez la source avant de l'exécuter.
Choosing between Server-Sent Events and WebSockets for real-time client connections, and implementing whichever you pick correctly — SSE wire format per WHATWG, WebSocket frame format per RFC 6455, automatic reconnection with `Last-Event-ID`, ping/pong heartbeats, the HTTP/1.1 6-connection-per-origin limit, and the proxy-buffering trap that silently breaks SSE. Grounded in WHATWG, RFC 6455, MDN. NOT for WebRTC, gRPC streaming, MQTT/IoT brokers, or third-party realtime services (Pusher, Ably).
{"category":"Real-Time & Streaming","tags":["sse","websocket","real-time","streaming","http","server-sent-events","eventsource","rfc-6455"],"provenance":{"kind":"first-party","owners":["port-daddy"]},"pairs-with":[{"skill":"websocket-realtime-expert","reason":"Once this skill's decision lands on WebSocket, that skill owns the server-side implementation depth (rooms, presence, scaling out)."},{"skill":"websocket-streaming","reason":"Covers the streaming-payload patterns (chunking, framing, fan-out) layered on top of whichever transport this skill picks."},{"skill":"real-time-collaboration-engine","reason":"Collaborative editing is the canonical bidirectional case that forces the WebSocket branch of this skill's decision diagram."}],"io-contract":{"kind":"deliverable","consumes":["[Truncated]","[Truncated]"],"produces":["[Truncated]","[Truncated]"]}}
Server-Sent Events vs WebSockets
TL;DR: SSE for server→client streams (notifications, log tails, LLM token streams, dashboards). WebSocket for bidirectional low-latency (chat, multiplayer, collaborative editors). SSE is plain HTTP with auto-reconnect baked in; WebSocket is a separate protocol with no built-in reconnect. The HTTP/1.1 6-per-origin connection limit kills SSE without HTTP/2; proxy buffering silently kills SSE if you forget X-Accel-Buffering: no on nginx.
flowchart TD
A[Need real-time browser-server channel] --> B{Direction?}
B -->|Server → Client only<br/>notifications, streams, dashboards| C[SSE]
B -->|Bidirectional<br/>chat, multiplayer, collab| D[WebSocket]
B -->|Mostly server-push,<br/>occasional client signal| E{Latency budget tight<br/>and message rate high?}
E -->|Yes| D
E -->|No, occasional POST is fine| C
C --> F{Behind nginx / proxy?}
F -->|Yes| G[Set X-Accel-Buffering: no<br/>+ disable response buffering]
F -->|No, direct connection| H[Set retry: + heartbeats]
D --> I[Implement reconnect logic<br/>+ ping/pong heartbeats<br/>+ sequence numbers for replay]
G --> J{HTTP/1.1?}
H --> J
J -->|Yes — 6 conn/origin limit applies| K[Migrate to HTTP/2<br/>or shard origins]
J -->|HTTP/2| L[Done]
I --> L
1. Wire formats
SSE (text/event-stream)
Per the WHATWG HTML Living Standard, the four field names are data, event, id, retry. Events terminated by a blank line.
Multiple data: lines in one event are concatenated with \n. A line starting with : is a comment (used for keepalives — see §4). The retry: value, per WHATWG, must be ASCII digits only:
If the field value consists of only ASCII digits, then interpret the field value as an integer in base ten, and set the event stream's reconnection time to that integer. Otherwise, ignore the field.
Frame header is 2–14 bytes (2 base + up to 8 extended length + 4 masking key).
Handshake comparison
SSE
WebSocket
Initial request
Plain GET with Accept: text/event-stream
GET with Upgrade: websocket, Sec-WebSocket-Key
Server response
200 OK + Content-Type: text/event-stream, response stream stays open
101 Switching Protocols + Sec-WebSocket-Accept (SHA-1 of key + GUID)
Auth, cookies, gzip
Inherited from HTTP
Lost after upgrade — must re-auth via subprotocol or first frame
Custom protocols
n/a
Sec-WebSocket-Protocol
The Sec-WebSocket-Accept value is computed (RFC 6455 §1.3): server takes the client's Sec-WebSocket-Key and concatenates the GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, SHA-1 hashes, and base64-encodes. Example: client dGhlIHNhbXBsZSBub25jZQ== → server s3pPLMBiTxaQ9kYGzzhZRbK+xOo=. This is what makes WebSocket NOT just a long-lived HTTP request — the server MUST acknowledge the protocol switch.
2. Reconnection models
SSE: automatic, with replay hint
EventSource reconnects automatically on close. From MDN:
By default, if the connection between the client and server closes, the connection is restarted.
The reconnect delay is server-controlled via retry: field (milliseconds). On reconnect, the browser sends Last-Event-ID: <last-id-seen> so the server can resume from there.
Critical caveat: Last-Event-ID is only useful if the server keeps a replay buffer keyed by ID. The protocol gives you the header for free; the durability is on you. A common bug: server emits ascending id: values but doesn't persist them, then 502s, then the client reconnects with Last-Event-ID: 42 and the server has no idea what came after 42.
// Client (browser, automatic)const es = newEventSource('/stream')
es.addEventListener('userconnect', (e) => { /* ... */ })
es.onerror = () => { /* EventSource auto-reconnects; just wait */ }
// Server (Node) — emit id: + persistlet lastId = Number(req.headers['last-event-id'] ?? 0)
const events = await store.getEventsAfter(lastId)
for (const ev of events) {
res.write(`id: ${ev.id}\n`)
res.write(`event: ${ev.type}\n`)
res.write(`data: ${JSON.stringify(ev.data)}\n\n`)
}
WebSocket: application layer, you own it
RFC 6455 has no built-in reconnection. Frame opcode 0x8 closes; section 7.4.1 defines codes (1000 normal, 1002 protocol error, etc.). Apps must implement reconnection, sequence numbers, and replay themselves.
When not used over HTTP/2, SSE suffers from a limitation to the maximum number of open connections, which can be especially painful when opening multiple tabs, as the limit is per browser and is set to a very low number (6)... This limit is per browser + domain, meaning you can open 6 SSE connections across all tabs to www.example1.com and another 6 SSE connections to www.example2.com.
When using HTTP/2, the maximum number of simultaneous HTTP streams is negotiated between the server and the client (defaults to 100).
Concrete impact: if your app holds 1 SSE connection per tab, the user opens a 7th tab on your domain and the SSE silently never connects. The browser holds the connection in pending state until one of the other 6 closes.
WebSocket does not suffer this — each WebSocket is a separate TCP connection after the upgrade, not part of the HTTP connection pool. (WebSocket-over-HTTP/2 per RFC 8441 exists but adoption is uneven.)
The fix for SSE: deploy behind HTTP/2. CloudFront, Cloudflare, modern nginx, modern Node http2 all do this. Verify with curl -I --http2 https://yoursite.com/stream showing HTTP/2 in the response.
4. Backpressure and heartbeats
SSE heartbeat
Send a comment line every 15-30s to defeat proxy idle timeouts:
: keep-alive
Many corporate proxies, load balancers, and CDNs close idle HTTP connections after 30-60s. Without keepalive comments, your "long-lived" SSE stream gets a TCP close and the client re-EventSources — burning a connection slot every minute.
WebSocket ping/pong
RFC 6455 control frames 0x9 (ping) and 0xA (pong). MDN:
When receiving a ping, send back a pong with the exact same Payload Data... Maximum payload length for pings/pongs: 125 bytes... If multiple pings arrive before sending a pong, only send one pong... Pongs received without prior pings should be ignored.
Most server libraries do this automatically. Verify by tcpdump'ing a long-idle connection — you should see periodic 2-byte (header-only) frames.
Backpressure: both fall back to TCP
Neither protocol has application-level flow control. Both rely on TCP's receive window. If the client can't keep up:
SSE: res.write() on the server starts blocking. If your event handler doesn't await it, you build an unbounded buffer in process memory.
WebSocket: server libraries expose bufferedAmount (browser side) or write-callback signals. The application must measure send-buffer growth and drop / batch / pause.
The pattern for both:
// Pseudocodeasyncfunctionsend(data) {
if (clientBufferTooLarge()) {
droppedMessages++
return// or batch, or close the connection
}
awaitwrite(data)
}
5. Proxy buffering: the SSE silent killer
The single most common SSE production bug: it works locally but messages arrive in batches every 10-60 seconds in production.
Cause: nginx (and many other proxies) buffer the response by default. Your server sends data; nginx holds it until its buffer fills or its timer fires; then it ships to the client.
Fix on the server:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
The X-Accel-Buffering: no header tells nginx to not buffer this response. (This is nginx-specific but most proxies that buffer support a similar opt-out header.) Always set it on SSE responses.
Traffic is server → client only (notifications, log tails, LLM token streams, dashboards, progress bars)
Traffic is bidirectional and latency-sensitive (chat, multiplayer, collaborative editors, control planes)
You want auto-reconnect for free
You need binary frames or sub-millisecond write paths
You ride existing HTTP infra (CDN, auth middleware, gzip, HTTP/2 multiplexing)
You need application-defined sub-protocols (Sec-WebSocket-Protocol)
Tolerance for ~text-only payloads
You accept owning reconnection, replay, and heartbeat logic
Per-message overhead matters less than operational simplicity
Connection counts on HTTP/1.1 origins would otherwise hit the 6 limit
The default for "stream tokens from an LLM to the browser" is SSE. Bidirectional chat is WebSocket.
Anti-patterns
Anti-pattern
Why it bites
Fix
SSE behind nginx without X-Accel-Buffering: no
Messages buffered into batches; "real-time" is anything but
Set the header; configure nginx with proxy_buffering off
Last-Event-ID honored but no server-side replay buffer
Client thinks it's resuming; server returns nothing
Persist event IDs + replay on reconnect, or document "no replay"
7+ tabs hit the same SSE origin on HTTP/1.1
Tabs ≥7 silently never connect
Migrate to HTTP/2; shard origins as a stopgap
WebSocket reconnect with no backoff
Reconnect storm on outage
Full-jitter exponential backoff (see circuit-breakers-and-retries)
Forgetting Sec-WebSocket-Accept validation in custom client
Server can be impersonated
Verify the SHA-1+GUID handshake response
Hand-rolled WebSocket client without masking
RFC 6455 says server MUST close — connection drops mysteriously
Use a library; or correctly mask all client→server frames
Sending JSON over text/event-stream with newlines unencoded
Multi-line data: parsing breaks
JSON-stringify (no embedded newlines) before emitting
Server holds DB connection per SSE client
Pool exhaustion at 6× concurrent users
Stream from a Redis pub/sub or a shared queue, not direct DB
Treating WebSocket close code 1006 as "normal"
1006 is the abnormal close code (no Close frame received)
Distinguish 1000 (normal) from 1006 (abnormal) for retry decisions
Novice / Expert / Timeline
Novice
Expert
Real-time channel
"WebSocket for everything"
SSE for unidirectional; WS for bidirectional
Reconnect
Forgets backoff; storms on outage
Full-jitter exponential; resumes from last seq/id
Behind a proxy
"It works on my machine"
Knows about X-Accel-Buffering: no; tests through prod-shaped infra
HTTP/2
Doesn't think about it
Verifies HTTP/2 enabled before relying on >6 SSE concurrent
Backpressure
Doesn't measure
Watches bufferedAmount / write blocks; drops or batches
Long-idle connection
Mysteriously disconnected
Sends keepalive comments / ping every 15-30s
Timeline test: simulate a 60-second backend outage. An expert SSE client reconnects automatically with Last-Event-ID and replays missed events. An expert WebSocket client backs off with jitter and resumes from sequence number. A novice implementation either reconnect-storms the recovering origin or silently drops events.
Quality gates
A real-time channel ships when:
Test: SSE responses include Cache-Control: no-cache and X-Accel-Buffering: no. Verified by curl on staging behind the same proxy as prod.
Test: SSE server emits id: for every event AND has a replay store keyed by id. Integration test that disconnects and re-connects with Last-Event-ID confirms missed events arrive.
Test: Heartbeat sent every ≤30s on idle connections (SSE: comment line; WS: ping frame). Verified by tcpdump or a 60-second idle integration test.
Test: WebSocket reconnect logic uses jittered exponential backoff capped at 30s. Unit test for the delay function.
Test: WebSocket message sequence numbers are persisted client-side and the server resumes correctly on reconnect.
Test: Browser uses HTTP/2 for the SSE origin. Verified by curl -I --http2.
Test: Per-connection backpressure exists — server drops/batches when client buffer exceeds threshold. Load test confirms memory stays bounded.
Manual: Production proxy chain doesn't add buffering. Test via curl through the actual prod LB/CDN.
Deterministic Audit
Before committing to a transport (or reviewing another agent's channel design), write it
as a JSON plan matching schemas/sse-ws-channel-plan.schema.json and run the
deterministic auditor:
auditSseWsChannel(plan) (in scripts/sse_ws_channel_audit.mjs) turns this skill's
decision diagram, anti-patterns, and Quality Gates into machine-checkable rules over
structured fields — no keyword matching: SSE picked for a bidirectional channel, SSE
behind a buffering proxy without X-Accel-Buffering: no, SSE on HTTP/1.1 (the
6-per-origin limit), a heartbeat slower than 30s, WebSocket reconnect without jittered
backoff, Last-Event-ID honored with no server-side replay buffer, and missing
backpressure handling. It returns { pass, score, findings, recommendations }.
examples/sample-input.json is a correctly-configured SSE plan behind nginx on HTTP/2
(pass: true). Version history lives in CHANGELOG.md.
NOT for this skill
WebRTC (use webrtc-data-channel-design for peer-to-peer / media)
gRPC streaming (use grpc-streaming-design)
Long polling (almost always wrong in 2026; use SSE)
HTTP/2 server push (deprecated by Chrome in 2022; not a substitute for either)
MQTT or other IoT pub-sub (use iot-message-broker-design)
Third-party real-time-as-a-service (Pusher, Ably, Firebase) — out of scope; this is the protocol-level skill