Real-Time Patterns - Polling, Long Polling, SSE and WebSockets
- HTTP only lets the client speak first. Every real-time trick is a way around that: ask again and again (polling), ask and wait (long polling), keep one response open (SSE), or switch to a different protocol on the same TCP connection (WebSocket).
- SSE is one-way (server to client), plain HTTP, with automatic reconnect and
Last-Event-IDresume built in. WebSocket is full duplex, binary or text, and starts life as an HTTPGETthat the server answers with101 Switching Protocols. - Client frames are always masked (4-byte key) so hostile page script cannot craft bytes that confuse a proxy; that makes client-to-server frame headers 6 to 14 bytes and server-to-client 2 to 10.
- An open connection is not free and can die silently: NATs and load balancers drop idle flows (AWS ALB defaults to 60 s), so send an application heartbeat well under that, and reconnect with exponential backoff plus jitter.
- Scaling push means fan-out: a message arriving at one server must reach sockets held by other servers, usually through a pub/sub backplane.
Mental model
You are waiting for a parcel. Polling is walking to the front door every five minutes to look. Long polling is calling the courier, who stays on the line until the van pulls up, then you hang up and call again. SSE is a radio broadcast: you tune in once and listen, but you cannot talk back through the radio. WebSocket is a phone line you leave open: either of you can speak at any moment.
Where it breaks: an open phone line costs the phone company a circuit for as long as it stays open, and so it does here: every held connection costs memory on a server, and a line that nobody is using can still be cut by a switch in the middle without either of you being told.
Plain HTTP is a function call: the client calls, the server returns, the call ends. Polling is a loop that calls getUpdates() on a timer. Long polling is the same call made blocking, so it returns only when there is something. SSE and WebSocket are a subscription: one long-lived stream, like a channel you range over or a message-broker consumer, where the server pushes and the subscriber reads.
Where it breaks: a function call has no state between invocations, but a subscription does. The server must remember every subscriber, handle the slow one that cannot keep up, and decide what the client missed while it was disconnected, none of which a stateless request ever had to answer.
How it works
Start with the constraint. In HTTP the client opens a request and the server answers it; a server that has news has nowhere to put it until the next request arrives. Four techniques get around this. They differ in who holds the connection open and for how long.
The four techniques side by side
| Polling | Long polling | SSE | WebSocket | |
|---|---|---|---|---|
| Direction | Client asks, server answers | Client asks, server answers late | Server to client only | Both directions, at any time |
| Transport | Many short HTTP requests | Many HTTP requests, each held open | One long HTTP response (text/event-stream) | HTTP GET, then 101, then WebSocket frames on the same TCP connection |
| Browser API | fetch in a timer | fetch in a loop | EventSource | WebSocket |
| Data format | Anything | Anything | UTF-8 text only | Text or binary frames |
| Proxy and firewall friendliness | Best: ordinary requests | Good: ordinary requests, but proxies may time out held ones | Good: plain HTTP, but a buffering proxy can hold events back | Needs the proxy to understand Upgrade; wss:// over 443 gets through most paths |
| Reconnect | Not needed | Client re-issues each request | Built in: EventSource retries and sends Last-Event-ID | None: you write it |
| Latency to see an event | Up to the poll interval | About one one-way delay | About one one-way delay | About one one-way delay |
| Typical use | Dashboards that tolerate staleness, job status | Fallback transport, restrictive networks | Notifications, live feeds, LLM token streams | Chat, games, collaborative editing, live trading |
What each costs: one worked example
Take a feed that produces one event per minute, watched for one hour: 60 events. Assume each HTTP request or response pair carries about 700 bytes of headers (cookies, user agent, Accept and so on; this is an assumption, measure yours in DevTools). With HTTP/1.1 keep-alive the TCP connection is reused, so a poll costs one request latency, not a new handshake (see HTTP and keep-alive).
| Technique | Requests per hour | Requests that carried data | Header overhead | Average added delay |
|---|---|---|---|---|
| Poll every 5 s | 3600 / 5 = 720 | 60 (8.3 percent) | 720 x 700 B = 504,000 B | 5 s / 2 = 2.5 s (worst case 5 s) |
| Long poll, 30 s server timeout | 2 per event = 120 | 60 | 120 x 700 B = 84,000 B | about one one-way delay |
| SSE | 1 | 60 | 700 B once, then about 8 B of framing per event (data: plus a blank line) = about 1.2 KB | about one one-way delay |
| WebSocket | 1 handshake | 60 | 700 B once, then 2 B per small server frame, plus a ping every 30 s (120 pings of 2 B, 120 pongs of 6 B) = about 1.8 KB | about one one-way delay |
The long-poll count comes from the timeline: an event arrives every 60 s and each request times out at 30 s, so each minute holds one request that ends in a timeout and one that ends in the event. Polling is not wrong; at 5 s it is simply paying for 660 empty round trips to learn 60 things. The picture below plays the same two events (A at 7 s, B at 19 s) through all four.
Now run it yourself. One server stream drives all four lanes; change the event rate and the poll interval and watch requests, bytes on the wire, latency and empty responses move. The WebSocket lane below the counters shows the real handshake and decoded frames from both sides, and a button that kills the network without telling anyone.
| Lane | Requests made | Bytes on the wire | Latency avg | Latency max | Empty responses | Events lost |
|---|---|---|---|---|---|---|
| Polling | 0 | 0 B | - | - | 0 | 0 |
| Long polling | 1 | 800 B | - | - | 0 | 0 |
| SSE | 1 | 700 B | - | - | 0 | 0 |
| WebSocket | 1 | 283 B | - | - | 0 | 0 |
Assumed sizes: 400 B request headers and 300 B response headers per HTTP exchange, 100 B per event, 50 ms one way. Long polling holds each request up to 30 s. SSE and WebSocket make one request, so their later events cost only framing.
The WebSocket lane in detail
GET /live HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Accept = base64(SHA-1(Sec-WebSocket-Key + the RFC 6455 GUID)), computed here from the key above. Handshake cost: 154 B + 129 B, paid once.
Client log (frames the client sent, masked)
- no frames yet
Server log (frames the server sent, never masked)
- no frames yet
Either side can send at any moment, with no request first. A server frame header is 2 to 10 B, a client frame header 6 to 14 B (the 4-byte mask key). The log shows the last six frames per side.
Long polling, step by step
- The client sends an ordinary
GET /updates?since=41. - The server does not answer. It parks the request until an event after id 41 exists, or until its own timeout (commonly 20 to 60 s) fires.
- On an event it replies
200with the data; on timeout it replies with an empty body or204. - The client immediately sends the next request. Anything that happened in the gap between two requests must be found by the
sincecursor, which is why long polling needs event ids. RFC 6202 collects the known pitfalls (RFC 6202).
Server-Sent Events
SSE is a standard HTTP response that never ends. The client sends Accept: text/event-stream; the server replies Content-Type: text/event-stream and keeps writing UTF-8 text, one event per blank-line-terminated block (HTML Standard, Server-sent events):
data:is the payload (severaldata:lines are joined with newlines).event:names the event type; omit it and the browser fires a plainmessageevent.id:sets the connection's last event id. If the connection drops,EventSourcereconnects on its own and sends aLast-Event-IDrequest header, so the server can replay what was missed.retry:tells the browser how many milliseconds to wait before reconnecting.- Lines starting with
:are comments; servers send them every 15 to 30 s so idle proxies do not close the response. - Text only. Binary has to be encoded (base64) which costs about a third more bytes.
- Over HTTP/1.1 a browser allows about 6 connections per origin, and each SSE stream uses one, so a few tabs can starve the page of connections. Over HTTP/2 the limit becomes the negotiated number of concurrent streams, 100 by default (MDN, Using server-sent events). See HTTP/1.1 and HTTP/2 for why.
- The client cannot send on the stream. It sends data with a normal
POST, which is fine for most apps.
The WebSocket handshake
A WebSocket begins as an ordinary HTTP/1.1 request on a TCP (and usually TLS) connection that already exists. The client asks to switch protocols; if the server agrees, the same connection carries WebSocket frames from then on (RFC 6455, section 1.3). For what Upgrade and 101 mean in general, see HTTP basics.
How the server builds Sec-WebSocket-Accept: append the fixed GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 to the client's key, take the SHA-1 hash, then base64 it. For the key above, dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11 gives s3pPLMBiTxaQ9kYGzzhZRbK+xOo=. You can verify that yourself in Hands-on.
The client must check the 101 status and the Accept value and fail the connection if either is wrong. Optional headers: Sec-WebSocket-Protocol negotiates a subprotocol (below) and Sec-WebSocket-Extensions negotiates extensions such as permessage-deflate (RFC 7692).
The frame layout
After the handshake the bytes on the wire are frames. Each message is one frame, or several fragments, each with a small header (RFC 6455, section 5.2).
Header size follows directly from that layout. The base header is 2 bytes. A payload of 126 bytes or more adds a 2-byte length (4 total), and 65,536 bytes or more adds an 8-byte length (10 total). Client frames add the 4-byte mask on top:
| Payload size | Server to client header | Client to server header |
|---|---|---|
| 0 to 125 bytes | 2 bytes | 2 + 4 = 6 bytes |
| 126 to 65,535 bytes | 2 + 2 = 4 bytes | 4 + 4 = 8 bytes |
| 65,536 bytes and up | 2 + 8 = 10 bytes | 10 + 4 = 14 bytes |
That is where the 2 to 10 and 6 to 14 byte ranges come from. RFC 6455 section 5.7 gives a concrete pair, the text Hello:
- Server to client, unmasked:
81 05 48 65 6c 6c 6f= 7 bytes:81is FIN plus opcode 1 (text),05is MASK 0 and length 5, then the five ASCII bytes. - Client to server, masked with key
37 fa 21 3d:81 85 37 fa 21 3d 7f 9f 4d 51 58= 11 bytes.85is MASK 1 and length 5; each payload byte is the plain byte XOR the key byte in turn (0x48 XOR 0x37 = 0x7f). Same message, 4 bytes bigger.
Deep dive
Masking: why client frames are always masked
Every client-to-server frame is XORed with a fresh, unpredictable 32-bit key sent in the clear next to it. This is not encryption: the key travels with the data and anyone can undo it. Its job is to stop a hostile page from controlling the exact bytes that appear on the wire. Without it, script in a browser could open a WebSocket through a transparent proxy that does not understand the protocol and send a payload shaped like an HTTP request (GET /popular.js ...). The proxy would see what looks like a second request and cache a poisoned response for a real URL. With a random mask chosen by the browser, the attacker cannot predict the wire bytes, so cannot forge that shape. RFC 6455 states this reason in section 10.3. Servers do not mask because the server is not running attacker-chosen code.
A server must close the connection if it receives an unmasked client frame, and a client must close if it receives a masked server frame.
Control frames: ping, pong, close
Three opcodes are control frames: close (8), ping (9) and pong (10). They carry at most 125 bytes and cannot be fragmented (RFC 6455, section 5.5). A receiver must answer a ping with a pong carrying the same payload. Two points surprise people:
- The browser
WebSocketAPI exposes neither sending nor receiving pings. The browser answers server pings automatically, but your script cannot send a ping or observe one. That is why applications add their own heartbeat message (a tiny text frame,{"t":"ping"}) and expect a reply. - A pong proves the whole path works. A ping that goes unanswered within a timeout means the path is dead even though no TCP error has surfaced, which is the entire reason to send it.
Closing: the handshake and the codes
Either side sends a close frame (opcode 8) with an optional 2-byte status code and reason; the other side replies with its own close frame, and then the TCP connection is closed, by the server first (RFC 6455, section 7). Codes worth knowing (section 7.4.1):
| Code | Meaning | Notes |
|---|---|---|
| 1000 | Normal closure | The purpose of the connection was fulfilled |
| 1001 | Going away | A server shutting down or a browser tab navigating away |
| 1002 | Protocol error | The endpoint received something that breaks the framing rules, for example an unmasked client frame |
| 1006 | Abnormal closure | Reserved: it is never sent in a close frame. Your client library reports it locally when the TCP connection ended with no close frame at all: a dropped network, a killed process or an LB reset |
| 1008 | Policy violation | A generic code for a message the endpoint cannot accept under its policy, for example a failed authorization check |
| 1011 | Internal error | The server hit an unexpected condition and cannot continue |
When your logs show 1006 and nothing else, treat it as "the line went dead without anyone saying goodbye" and look at proxies, idle timeouts and deploys, not at your own application code.
Silent death and heartbeats
A TCP connection can break without either endpoint being told: a NAT table entry expires, a load balancer reaps an idle flow, a laptop sleeps, a mobile handset changes network. Neither side sends a FIN or RST, so each keeps believing the other is there.
- AWS ALB closes a connection idle for 60 s by default (ALB idle timeout); nginx's
proxy_read_timeoutalso defaults to 60 s (nginx docs). Pick a heartbeat interval below the smallest idle timeout on the path, with room for one lost beat: 25 to 30 s against a 60 s limit. - TCP keepalive is not a substitute. Linux defaults to waiting 7,200 s (two hours) before the first probe (kernel ip-sysctl docs,
tcp_keepalive_time), far longer than any middlebox timeout, and it only talks to the nearest TCP endpoint, which may be the load balancer rather than your server. See TCP connection management for how keepalive works. - Detection rule: if no pong or heartbeat reply arrives within, say, two heartbeat intervals, close and reconnect; do not wait for a TCP error that may never come.
Reconnect with backoff and jitter
A connection will drop, so the client needs a reconnect loop. Two failure modes to avoid:
- Reconnect immediately, forever. If a server deploy drops 1,000,000 sockets at once and every client retries instantly, the new instances face 1,000,000 handshakes in the same second: a thundering herd that can knock over the thing that just came back.
- Backoff without jitter. If every client waits exactly 1 s, then 2 s, then 4 s, they all still retry in lockstep.
The fix is exponential backoff with jitter: before attempt n, sleep a random time between 0 and min(cap, base * 2n). This "full jitter" form spreads retries and measurably cuts total work compared with plain backoff (AWS Architecture Blog, Exponential Backoff and Jitter). Spread 1,000,000 reconnects evenly over a 30 s window and the arrival rate falls to about 33,000 per second instead of 1,000,000 at once. The general retry and backoff policy is owned by timeouts, retries and backoff; this page only adds the real-time part.
Reconnecting is only half of it. Resume: the client sends the id of the last message it processed, and the server replays what came after. SSE gives you this in the protocol (Last-Event-ID). With WebSocket you build it: number every message, keep a bounded replay buffer per room or user, and have the client send {"resume_from": 1042} on connect. If the gap exceeds the buffer, force a full state refresh.
Subprotocols
A WebSocket carries opaque messages; the subprotocol says what they mean. The client lists candidates in Sec-WebSocket-Protocol: chat, superchat; the server picks one and echoes it in the 101. If it names one the client never offered, the client must fail the connection (RFC 6455, section 1.9). Names are registered with IANA (graphql-transport-ws, mqtt and others), which lets one endpoint serve different clients and lets you version your message format.
WebSocket over HTTP/2 and HTTP/3
The Upgrade mechanism exists only in HTTP/1.1, and HTTP/2 multiplexes many streams over one TCP connection, so a single stream cannot take over the connection. RFC 8441 bootstraps WebSocket on an HTTP/2 stream with an extended CONNECT request (a :protocol pseudo-header, enabled by the server's SETTINGS_ENABLE_CONNECT_PROTOCOL), so a WebSocket costs one stream rather than one TCP connection. RFC 9220 does the same for HTTP/3. Support depends on the server and every proxy in front of it; many deployments still fall back to HTTP/1.1 for WebSocket.
Scaling push connections
Serving 1,000,000 mostly idle WebSocket clients is a different problem from serving 1,000,000 requests per minute. Four things hurt.
1. Per-connection memory. Each socket holds kernel buffers, TLS state and your application's session object. The cost is workload dependent; assume 20 KB as a planning number (an assumption: measure your own) and 1,000,000 sockets means about 20 GB across the fleet before any business data.
2. Connection limits at the load balancer. A client-facing address supports an enormous number of clients because each one has a different source address and port. The pinch is the load balancer to backend leg: an L7 balancer opening connections from one source IP to one backend IP:port is limited by the ephemeral port range. Linux's default ip_local_port_range is 32768 to 60999, which is 60999 - 32768 + 1 = 28,232 ports. So 1,000,000 connections need at least 1,000,000 / 28,232, about 36 backends per balancer source IP (about 16 if you widen the range to roughly 64,000 ports), or more balancer IPs.
3. Fan-out across servers. A WebSocket connection lives on exactly one server for its whole life. When user 1 on server A posts to a room that has members on servers B and C, A cannot write to B's sockets. Two designs:
- Sticky routing by room: make every member of a room land on the same server, so fan-out is a local loop. This breaks when a room outgrows one machine and needs a routing layer that knows rooms.
- A pub/sub backplane: every server subscribes to the channels its connected clients care about, and publishes inbound messages to the channel. Redis Pub/Sub is the lightweight option, with an at-most-once guarantee: a subscriber that is disconnected misses the message (Redis Pub/Sub docs). Kafka or another log gives durable replay for resume at the cost of more machinery.
4. Backpressure and slow consumers. A client on a bad mobile link reads slower than you write. If the server keeps queueing messages for it, memory grows without bound and one slow client can take a node down. Give every connection a bounded outbound queue and a policy when it fills: drop old messages (fine for a price ticker, where only the latest matters), coalesce them, or disconnect the client and let it resume. On the browser side WebSocket.bufferedAmount reports how much you have queued but the network has not accepted, so a producer can pause.
Stickiness is separate. A pure WebSocket does not need sticky sessions, since the TCP connection already pins it to one server. You need stickiness for long-polling fallbacks (each request must find the server holding the session; Socket.IO documents this requirement, Socket.IO, How it works) and for any resume scheme that keeps state in server memory. For balancing and connection draining in general, see load balancing.
Authenticating a WebSocket
The browser WebSocket constructor takes a URL and optional subprotocols and cannot set custom headers, so you cannot send Authorization: Bearer ... the way you do with fetch (MDN, WebSocket). Three workable designs:
- Cookie. The browser attaches your session cookie to the handshake
GETautomatically, so the server authenticates it like any request. Because a cookie is sent even when a page on another origin opens the socket, the server must also check the handshakeOriginheader against an allow-list, or it is open to cross-site WebSocket hijacking (RFC 6455, section 10.2). Sessions, cookies and the same-origin rules are owned by the cookies, sessions and CORS page. - Short-lived ticket in the query string. The page first calls an authenticated HTTPS endpoint to mint a single-use ticket that expires in seconds, then connects to
wss://host/socket?ticket=.... The weakness of putting secrets in URLs is that URLs end up in access logs and proxies; a ticket that is single-use and expires in seconds makes a leaked log line worthless. - First-message auth. Accept the connection unauthenticated, require the first frame to be
{"auth": "<token>"}, and close with 1008 if it does not arrive within a few seconds or is invalid. Nothing sensitive reaches logs, but you hold an unauthenticated socket briefly, so cap it with a short deadline.
Always use wss://, never ws://: TLS protects the cookie and token, and in practice also gets through more proxies, which tend to leave tunnelled TLS alone.
Webhooks and WebRTC in one paragraph each
Webhooks are server-to-server push: instead of you polling GitHub or Stripe, they POST to a URL on your server when something happens. The sender signs the body (an HMAC in a header) so you can verify it, retries on failure, and expects a fast 2xx reply, so your handler should acknowledge first and process later. Because delivery is at-least-once, handlers must be idempotent. gRPC streaming and the choice between REST, gRPC and GraphQL are covered on the RPC page, not here.
WebRTC is the peer-to-peer option: browsers exchange audio, video or arbitrary data directly, using a signalling channel (often a WebSocket) only to set up the call. Data channels run over SCTP inside DTLS over UDP (RFC 8831). Reach for it when latency matters more than server-side control.
Textbook vs real world
| The textbook says | Production does | Why |
|---|---|---|
| Use WebSockets for anything real-time | Use SSE for server-to-client feeds and plain POST for the return path; keep WebSocket for genuinely two-way traffic | SSE is plain HTTP: it works with existing auth, logging, HTTP/2 and caching rules, reconnects and resumes itself, and has far fewer proxy surprises |
| A WebSocket connection is cheap compared with HTTP requests | It is cheap per message and expensive per held connection: memory, file descriptors, idle-timeout management and fan-out are the cost | A request ends and frees its resources; a socket stays until someone closes it, whether or not it is used |
| Long polling is obsolete | Socket.IO still starts every connection on HTTP long polling and upgrades to WebSocket when it can; it also works as the fallback when a proxy blocks the upgrade | Plain HTTP passes through every middlebox that has ever existed |
| TCP keepalive keeps the connection alive | Applications send their own heartbeat every 20 to 30 s | Default keepalive waits 2 hours and may only reach the nearest middlebox; the application is the only party that can prove the peer is alive |
| The server sends data and the client receives it | Add sequence ids, a replay buffer and a resume handshake | Connections drop constantly on mobile networks, so "what did I miss?" is a core requirement |
| Handshake auth is a header | Cookie, single-use ticket or first-message token | The browser WebSocket API cannot set headers |
In production
- Discord's gateway is a WebSocket. After connecting, the server sends a Hello with a
heartbeat_interval, and the client must keep sending heartbeats at that interval, which is exactly the application-level heartbeat described above (Discord Gateway docs). Discord has also written about handling millions of concurrent sessions on Elixir (How Discord Scaled Elixir to 5,000,000 Concurrent Users); read it for the fan-out design. - Slack's Socket Mode lets an app receive events over a WebSocket it opens to Slack, instead of exposing a public HTTP endpoint (Slack Socket Mode). This inverts the webhook model for apps behind a firewall.
- LLM token streaming is SSE. Setting
stream: trueon OpenAI's API returns the completion as server-sent events (OpenAI streaming guide); Anthropic's Messages API streams typed events such asmessage_startandcontent_block_deltaastext/event-stream(Anthropic streaming docs). It is a one-way feed and the request is a normalPOST, which is why SSE fits. - Webhooks are how GitHub and Stripe push events to your server, signed and retried (GitHub webhooks, Stripe webhooks).
- Wikimedia EventStreams publishes the live stream of wiki edits as SSE to anyone with
curl(EventStreams docs); the Hands-on section uses it. - Frameworks and hosted services wrap these patterns: Phoenix Channels (WebSocket with a long-poll fallback), Socket.IO, Pusher and Ably all add rooms, presence, reconnect and resume on top of the raw protocols described here.
History and evolution
- 1990s The web is request/response only. Pages that need fresh data reload on a timer (the HTML meta refresh) or poll with scripts: simple, wasteful, and delayed by the poll interval.
- 2006 Alex Russell names the long-held-request technique Comet (Comet: Low Latency Data for the Browser). It works through any proxy but ties up a held request per client.
- 2009 HTML5 drafts add
EventSource(Server-Sent Events) and theWebSocketAPI, both aimed at ending Comet hacks. - 2011 RFC 6455 standardises WebSocket, including the mandatory client masking added as a defence against proxy cache poisoning (its section 10.3 describes the attack). RFC 6202 documents long polling and streaming best practices the same year.
- 2015 HTTP/2 (RFC 7540, now RFC 9113) multiplexes streams, which removes the 6-connection pain for SSE; RFC 8441 (2018) later lets WebSocket ride an HTTP/2 stream.
- 2022 RFC 9220 brings WebSocket bootstrapping to HTTP/3; HTTP/3 itself is RFC 9114.
- Now SSE carries most LLM token streaming; WebSocket remains the default for chat, games and collaborative apps; webhooks are the standard server-to-server push.
- Next WebTransport (a W3C API and IETF drafts over HTTP/3) offers multiple streams and unreliable datagrams on one QUIC connection, aimed at cases where WebSocket's single ordered stream hurts; it is still maturing, so check current browser support before depending on it.
Hands-on
Watch a real SSE stream with curl (-N turns off output buffering so events print as they arrive; the endpoint is Wikimedia's public edit feed):
Notice the three fields from the SSE section: event, id and data, separated from the next event by a blank line. Output is abbreviated and the exact fields depend on the service.
Do the WebSocket handshake by hand with curl, using the RFC's example key. You need --http1.1 because the upgrade does not exist in HTTP/2, and --max-time because the connection would otherwise stay open:
Any compliant server returns that exact Sec-WebSocket-Accept for that key. echo.websocket.org is a public service that has changed hands before, so substitute any WebSocket endpoint if it is down; extra headers will vary.
Recompute the Accept value yourself (works on macOS and Linux):
Talk to a socket with websocat (brew install websocat on macOS) or wscat (npm i -g wscat):
Type a line and the echo comes back. Add -v to see connection events.
See frames in the browser: open DevTools, go to the Network tab, filter by WS, click the connection and open Messages. Each row is one frame with its direction and size; the Headers tab shows the handshake.
See the idle drop yourself: open a socket to a service behind a load balancer and send nothing. The connection will end near the balancer's idle timeout, and your client will report close code 1006 with no close frame.
Gotchas
Interview questions
Show answer
Polling asks the server on a timer, so updates arrive up to one interval late and most requests return nothing. Long polling asks and the server holds the request until it has data, then the client asks again. SSE is one long HTTP response the server keeps writing events to, one direction only. WebSocket upgrades the connection to a full-duplex frame protocol where either side can send at any time.
Follow-ups:
- When would you choose SSE over WebSocket? When data flows server to client only (notifications, feeds, LLM streaming). It is plain HTTP, reconnects and resumes on its own with
Last-Event-ID, and the client can still send viaPOST. - What is the downside of SSE? Text only, and over HTTP/1.1 about 6 connections per origin, so several tabs can exhaust it.
- Does polling ever win? Yes, when updates are rare, staleness is fine and you want the simplest thing that works through any proxy and caches well.
Show answer
The client sends an HTTP/1.1 GET with Upgrade: websocket, Connection: Upgrade, a random base64 Sec-WebSocket-Key and Sec-WebSocket-Version: 13. The server replies 101 Switching Protocols with Sec-WebSocket-Accept, which is the base64 of SHA-1 of the key plus a fixed GUID (258EAFA5-E914-47DA-95CA-C5AB0DC85B11). The client checks it, and from then on the same TCP connection carries WebSocket frames instead of HTTP.
Follow-ups:
- What is
Sec-WebSocket-Acceptfor? It proves the server read a WebSocket handshake rather than blindly answering an HTTP request. It is not authentication or secrecy, since anyone can compute it. - What do subprotocols do? The client lists them in
Sec-WebSocket-Protocol, the server picks one and echoes it, so both sides agree what the messages mean. - Why
Origin? Browsers send it so the server can reject handshakes from pages it does not trust.
Show answer
Treat it as three problems. First, hold the sockets: budget memory per connection (about 20 GB at an assumed 20 KB each), raise file descriptor limits, and spread across many backends because the balancer-to-backend leg is limited by ephemeral ports (about 28,000 per source IP and backend on Linux defaults). Second, fan out: a connection lives on one server, so publish messages through a pub/sub backplane such as Redis or Kafka that every server subscribes to. Third, survive restarts: heartbeats below the load balancer's idle timeout, jittered reconnect, and resume with last id.
Follow-ups:
- Sticky sessions or pub/sub? Pure WebSocket is already pinned to one server by TCP, so stickiness matters for long-poll fallbacks and in-memory resume state. Pub/sub is what delivers a message to sockets on other servers.
- What happens at the load balancer's idle timeout? The balancer drops the connection silently (ALB: 60 s by default). A heartbeat every 25 to 30 s keeps it alive and also detects dead peers.
- What about slow consumers? Bounded per-connection queues; drop, coalesce or disconnect instead of buffering forever.
Show answer
The browser WebSocket API cannot set custom headers, so there are three options. Use the session cookie, which the browser sends on the handshake, and validate Origin to block cross-site hijacking. Or mint a short-lived single-use ticket over an authenticated HTTPS call and pass it in the query string. Or accept the socket and require an auth token in the first message, closing with 1008 if it fails or does not arrive in a few seconds.
Follow-ups:
- Why not put a long-lived token in the URL? URLs are written to access logs and proxy logs, so the token would leak. A ticket that is single-use and expires in seconds is harmless once logged.
- What is cross-site WebSocket hijacking? A page on another site opens a socket to your server; the browser attaches your cookie, so without an
Origincheck the attacker's page acts as the logged-in user. - Does
wss://handle auth? No, it provides encryption and server identity. Client authentication is still your job.
Show answer
Use an application-level heartbeat: send a small ping message every 20 to 30 s and expect a reply within a timeout. If none arrives, close the socket and reconnect. The failure it catches is silent death: a NAT or load balancer dropped the flow, and neither side received a FIN or RST, so the connection looks open forever.
Follow-ups:
- Why is TCP keepalive not enough? Linux defaults to a 7,200 s first probe, which is longer than any middlebox timeout, and it checks only the nearest TCP endpoint, not your application.
- Why can I not use WebSocket ping frames from the browser? The browser API does not expose them; it answers server pings but never lets script send one.
- How do I choose the interval? Below the smallest idle timeout on the path (ALB defaults to 60 s), allowing one missed beat.
Show answer
Wait with exponential backoff and full jitter: sleep a random time between 0 and min(cap, base * 2attempt), reset on a successful connection that stays up. On reconnect send the last message id received so the server can replay the gap, and fall back to a full state fetch if the gap is too large. Without jitter and a cap, clients retry in lockstep and without resume they lose data.
Follow-ups:
- What is a thundering herd? Many clients retrying at the same moment, for example after a deploy drops all sockets, overloading the server that just came back.
- Why jitter as well as backoff? Backoff alone still synchronises clients that failed together; randomness spreads them out.
- What does SSE give you for free? Automatic retry with the server-controlled
retry:delay andLast-Event-IDresume.
Show answer
To stop cache poisoning through intermediaries that do not understand WebSocket. Without masking, hostile script could send bytes that look like an HTTP request, and a transparent proxy might treat them as a real request and cache a forged response for a real URL. Masking each client frame with a fresh random 4-byte key means the attacker cannot control the wire bytes. It is not encryption, since the key travels in the same frame.
Follow-ups:
- Do servers mask? No; they are not running attacker-controlled code, so server frames are unmasked and their header is 4 bytes smaller.
- What does a server do with an unmasked client frame? It must close the connection with a protocol error (1002).
- What is the byte cost? Client frame headers are 6 to 14 bytes instead of 2 to 10.
Show answer
Live scores are one-way, so SSE (or WebSocket) from edge servers, with the score service publishing to a pub/sub backplane that each edge subscribes to, and Last-Event-ID for resume. Chat is two-way, so WebSocket over wss, with sockets terminating on a fleet of connection servers behind a load balancer that has an idle timeout above the heartbeat interval. Messages get a per-room sequence number and are written to a durable log first, then published to the backplane, so a reconnecting client asks for everything after its last id.
Follow-ups:
- How do you handle fan-out for a room with 100,000 members? Each connection server subscribes once per room and loops over its local sockets, so the backplane sends 1 message per server, not 1 per client.
- How do you preserve ordering? TCP orders per connection; per-room sequence numbers assigned at one place (or one partition) order across servers and let clients detect gaps.
- How do you avoid missed messages? Persist before publish, replay from the log on resume, and make the client idempotent by deduplicating on message id.
Fun facts
- The GUID
258EAFA5-E914-47DA-95CA-C5AB0DC85B11in theSec-WebSocket-Acceptcomputation is hard-coded in RFC 6455 and has to be identical in every implementation. - Close code 1006 can never be sent on the wire: the RFC reserves it so a library can report "the connection ended with no close frame".
- Ping and pong payloads are limited to 125 bytes, because control frames cannot use the extended length fields.
- The RFC's own masking example is the five-letter message
Hello, which costs 7 bytes from a server and 11 from a client.
Cheat sheet
- 2 to 10 / 6 to 14 bytes - WebSocket frame header, server to client / client to server (4-byte mask)
- 125 bytes - maximum control frame (ping, pong, close) payload
- 1000 / 1001 / 1006 - close codes: normal / going away / abnormal, no close frame
- 60 s - AWS ALB default idle timeout; heartbeat every 25 to 30 s
- 7,200 s - Linux default
tcp_keepalive_time - 6 - HTTP/1.1 connections per origin in browsers, which limits SSE streams
- 28,232 - Linux default ephemeral ports (32768 to 60999) per source IP and backend
258EAFA5-E914-47DA-95CA-C5AB0DC85B11- the GUID inSec-WebSocket-Accept