WebSocket vs SSE vs polling: how each works and when to use it
Polling sends a new request each round, SSE holds one response open for server events, WebSocket upgrades to two-way frames. Reconnects, proxies and firewalls.
Intermediate9 min read
POLLING a new request every 3 s GET /updates client ──────────────────────────────────────────── server ● ← 204 No Content ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ SSE one request, the response stays open GET /events Accept: text/event-stream client ──────────────────────────────────────────── server ● ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ WEBSOCKET one upgrade, then frames both ways GET /ws Upgrade: websocket client ──────────────────────────────────────────── server ● same data, three connection shapes
On this page
The short answer: if data only flows from the server to the browser (job status, notifications, sensor readings), start with SSE (Server-Sent Events). If both sides send often, as in chat or collaborative editing, use WebSocket. Polling fits when data changes rarely and a delay of several seconds is fine, or when the network in between won't tolerate long-lived connections.
The three differ in what the connection looks like on the wire, and that difference reaches every proxy, load balancer and firewall along the path. Below is each one at the request and response level, how it recovers from a dropped connection, and what has to be configured in the middle.
POLLING a new request every 3 s GET /updates client ──────────────────────────────────────────── server ● ← 204 No Content ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ SSE one request, the response stays open GET /events Accept: text/event-stream client ──────────────────────────────────────────── server ● ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ WEBSOCKET one upgrade, then frames both ways GET /ws Upgrade: websocket client ──────────────────────────────────────────── server ● same data, three connection shapes
How does polling work?
The client sends an HTTP request at intervals to ask whether anything is new. RFC 6202 (April 2011) describes two kinds.
With short polling, the client asks on a fixed schedule; if there is nothing new, the server returns an empty response and the client waits for the next round. The catch is that server and network load strongly depend on the acceptable latency. If updates must arrive within seconds, the polling rate can become a real burden.
GET /api/updates?since=1041 HTTP/1.1
Host: app.example.com
HTTP/1.1 204 No Content
With long polling, the server doesn't answer straight away. It holds the request until there is an update, or until a timeout. The client usually sends the next request as soon as a response arrives, so the server almost always has one request waiting.
RFC 6202 lists the costs. Three come up most:
- Headers every round. Each long poll request and response is a complete HTTP message with a full set of HTTP headers. For small messages, the headers can be most of what is sent.
- Worst-case latency. After a response, the server must wait for the next request before it can send again. Average latency is close to one network transit, but the maximal latency is over three.
- Held resources. Each client holds a TCP connection and an HTTP request open, and the number of outstanding requests can be limited on some gateways, proxies, and servers.
The upside is that polling is plain HTTP throughout. RFC 6202 notes that most proxies work well with HTTP long polling, because every response sent is a complete one.
How do server-sent events work?
The client sends one GET; the server replies with text/event-stream, never finishes the response, and keeps appending events to it. In the browser this is the EventSource interface from the WHATWG HTML Standard.
const source = new EventSource("/api/events");
source.addEventListener("job", (e) => console.log(e.lastEventId, e.data));
On the wire:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-store
: keep-alive
id: 1042
event: job
data: {"job": 7, "state": "running"}
id: 1043
event: job
data: {"job": 7, "state": "done"}
The format has only a few rules (WHATWG, 9.2.6):
| Line | Meaning |
|---|---|
data: |
Event data; several data: lines are joined with newlines |
event: |
Event name; without it the event is a message |
id: |
Sets the last event ID used on reconnect |
retry: |
Sets the reconnection delay in milliseconds |
A line starting with : |
A comment; ignored |
| A blank line | Dispatches the event built so far |
Event streams must always be encoded as UTF-8, so raw binary is out. SSE is also a one-way connection: anything the client needs to send goes in a separate request, such as an ordinary POST.
How does WebSocket work?
A WebSocket starts as an HTTP request asking to switch protocols, and after that it is no longer HTTP. RFC 6455 (December 2011) gives this handshake (RFC 6455):
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The server builds Sec-WebSocket-Accept by appending the fixed GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 to the key, then taking the SHA-1 hash and base64-encoding it, which proves it actually speaks WebSocket. The RFC is explicit that WebSocket is an independent TCP-based protocol whose only relationship to HTTP is that the handshake is read as an Upgrade request.
After the 101, data moves in frames. Each frame has an opcode: 0x1 text, 0x2 binary, 0x8 close, 0x9 ping, 0xA pong (RFC 6455, 5.2). Either side can send at any time over the one TCP connection.
One detail matters for network people. A client must mask all frames it sends, with a fresh random 32-bit key per frame, and the server must not mask its frames. While the protocol was being designed, an experiment poisoned caching proxies deployed in the wild by sending data formatted as a GET request over an upgraded connection. Masking stops the attacker's script from controlling the bytes on the wire. The key travels inside the frame, though, so anyone who can read the frame can unmask it: masking is not encryption. Confidentiality comes from TLS on wss://, whose default port is 443.
Two browser-side limits are worth knowing. The WebSocket interface doesn't support backpressure: if messages arrive faster than the app handles them, they pile up in memory. And a page with an open WebSocket may not be added to the bfcache, so close the connection when the user is done with the page.
What happens when the connection drops?
SSE reconnects by itself; with WebSocket you write the reconnect. In my view this is the difference that matters most in practice.
SSE. If the connection closes, the browser reconnects automatically after the reconnection time, which starts at an implementation-defined value probably in the region of a few seconds and can be changed with retry:. On reconnect it sends a Last-Event-ID header with the last id: it saw:
GET /api/events HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 1043
A server that keeps recent events can resume at 1044 instead of resending everything. The browser stops reconnecting in two cases: the server answers 204 No Content, or the response is not a 200 or not text/event-stream, which fails the connection, with no further attempts. The second case means a single 502 from a proxy can end the stream for good. The app should listen for error and open a new EventSource itself when readyState is CLOSED.
WebSocket. RFC 6455 defines nothing like Last-Event-ID. If you need to resume from the last message, that goes into your own application protocol, for example by having the client send its last sequence number as the first message after reconnecting. On timing, the RFC says clients SHOULD use some form of backoff: delay the first attempt by a random amount, where between 0 and 5 seconds is a reasonable start, and wait longer after each failure, for example with truncated binary exponential backoff. Without it, a server outage followed by every client reconnecting at once amounts to a denial-of-service attack.
Polling. There is no connection to lose; the next request is the reconnect. Send a cursor such as since= every time so nothing is missed between rounds.
Which way can data flow with each one?
Side by side:
| Polling | SSE | WebSocket | |
|---|---|---|---|
| Direction | Client asks, server answers | Server to client | Both ways |
| Connection | New request each round | One long-lived response | One TCP connection after the upgrade |
| Payload | Anything HTTP can carry | UTF-8 text | Text or binary frames |
| Reconnect | Every round is one | Browser does it, with Last-Event-ID |
Your code does it |
| What intermediaries see | Normal HTTP | A very long HTTP response | HTTP only during the handshake |
How do proxies, load balancers and firewalls treat each one?
Very differently. Polling is ordinary HTTP. SSE is an HTTP response that doesn't end. WebSocket is HTTP only at the start, then a different protocol on the same TCP connection. Most real-world trouble comes from the following.
Idle timeouts
Most intermediaries close connections that go quiet. nginx is a clear example: by default a proxied WebSocket connection is closed if the backend sends no data within 60 seconds. Raise proxy_read_timeout, or have the backend send ping frames.
The fix for each:
- WebSocket: send ping frames, which the RFC says may serve as a keepalive. The receiver must answer with a pong.
- SSE: send a comment line such as
: keep-alive. The HTML Standard suggests one every 15 seconds or so, because some legacy proxies drop HTTP connections after a short timeout. - Long polling: set the server's hold time below the intermediaries' timeouts. RFC 6202 calls 30 seconds generally a safer value, although experiments succeeded with up to 120.
Buffering
SSE only works if each event reaches the browser when it is written, but a reverse proxy may buffer the response first. In nginx, proxy_buffering is on by default, so events wait at the proxy until a buffer fills. Turn it off in config, or have the backend send X-Accel-Buffering: no on the stream's response. The HTML Standard warns about the same class of problem: chunking done by a layer unaware of timing can hurt the reliability of the protocol.
The Upgrade header
Upgrade and Connection are hop-by-hop headers, so a reverse proxy does not pass them to the backend on its own. Without extra config, the backend sees a plain GET and never upgrades. In nginx:
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
location /api/events {
proxy_pass http://backend;
proxy_buffering off;
proxy_read_timeout 300s;
}
A consequence follows: each WebSocket or SSE connection stays on one backend for its whole life, and the load balancer can only move a client when it reconnects. Draining or restarting one backend therefore makes all of its clients reconnect at the same moment, which is one more reason for backoff.
Connection limits and HTTP/2
Without HTTP/2, SSE runs into the browser's connection limit: 6 per browser per domain, across all tabs. If each tab opens one stream, the seventh tab on the same site can't connect. Over HTTP/2 the number of concurrent streams is negotiated and defaults to 100. The HTML Standard also suggests sharing one EventSource through a shared worker.
Classic WebSocket uses a TCP connection of its own. RFC 8441 (September 2018) defines how to run WebSocket over a single stream of an HTTP/2 connection using extended CONNECT, which the server must first advertise with SETTINGS_ENABLE_CONNECT_PROTOCOL.
Firewalls and inspection
On the security side I always check two things:
- What an HTTP-aware device sees. Polling and SSE stay HTTP end to end, so policies based on URL, method or headers keep working. After the
101, WebSocket is frames of a different protocol; a device that only parses HTTP sees the handshake and nothing after it. Withwss://it is all inside TLS, so the device must decrypt to see even the handshake. - The
Origincheck at the handshake. A script on any page the browser has open can try to open a WebSocket to your server. The RFC says servers meant only for certain sites SHOULD verify theOriginfield and reply403 Forbiddenwhen it isn't one they expect.
Which one should you choose?
Work through these in order:
- Does the client need to send often while it is also receiving? If yes (chat, games, shared editing), use WebSocket, and plan to write reconnect, backoff and resume-from-last-message yourself.
- Does data flow one way, from the server, as text? If yes, use SSE. You get reconnects and
Last-Event-IDfor free, and it stays HTTP that intermediaries understand. Turn off buffering and send keep-alive comments. - Does data change rarely, or do clients sit behind proxies you don't control? If yes, polling is the simplest option, easy to test, and cacheable and loggable like any other HTTP.
- What proxies, load balancers and firewalls are in the path? Check each one's idle timeout, buffering and
Upgradeforwarding before you commit. All three approaches work end to end; one intermediary configured the wrong way is enough to drop connections or hold events.
Whichever you pick, give every message a sequence number and have the client send the last one it saw each time it reconnects or polls. All three can drop, and the sequence number is what keeps data from going missing.
Sources
- RFC 6455: The WebSocket Protocol (December 2011)
- RFC 6202: Known Issues and Best Practices for the Use of Long Polling and Streaming in Bidirectional HTTP (April 2011)
- RFC 8441: Bootstrapping WebSockets with HTTP/2 (September 2018)
- WHATWG HTML Standard: 9.2 Server-sent events
- MDN: Using server-sent events
- MDN: WebSocket API (WebSockets)
- nginx: WebSocket proxying
- nginx: Module ngx_http_proxy_module
Keep reading
Related posts
Building software
RPC vs REST: what's the difference, and which should you use?
RPC calls a named procedure on a server; REST sends requests to resources through HTTP's uniform interface. A side-by-side comparison with gRPC and JSON-RPC examples, from Fielding, RFC 9110 and the specs.
Beginner10 min read
- API
- RPC
- REST
- gRPC
- JSON-RPC
- HTTP
Building software
How do offline-first apps keep working without a connection?
Offline-first apps write to a local database first and sync through a queue. One save step by step, plus retries and conflicts, from Android, Flutter and MDN.
Intermediate9 min read
- offline-first
- sync
- IndexedDB
- Service Worker
- WorkManager
- Flutter
- HTTP
Building software
How do HTTP/1.1, HTTP/2 and HTTP/3 differ?
All three share the semantics in RFC 9110. HTTP/1.1 sends text over TCP, HTTP/2 sends binary frames over one TCP connection, HTTP/3 runs on QUIC over UDP.
Intermediate9 min read
- HTTP
- HTTP/2
- HTTP/3
- QUIC
- TCP
- TLS
- Firewalls