# SSE vs WebSockets vs Long Polling

> Server-Sent Events vs WebSockets vs long polling: direction, reconnects with Last-Event-ID, the 6-connection limit and HTTP/2, nginx proxy buffering, and RFC 8441.

Source: https://howhttpworks.com/compare/sse-vs-websockets
Last reviewed: 2026-10-04

> **TL;DR:** Use Server-Sent Events when updates flow one way, server to browser: it is plain HTTP, reconnects by itself and resumes with `Last-Event-ID`. Use WebSockets when both sides send messages continuously or you need binary frames. Long polling is the fallback when neither can get through a network. Whichever you pick, a proxy that buffers responses is the most common reason it "doesn't work".

## Side By Side

| | Server-Sent Events | WebSockets | Long polling |
|---|---|---|---|
| Direction | Server to client | Both ways | Client asks, server answers when it has data |
| Protocol | Ordinary HTTP response, `Content-Type: text/event-stream` | Upgrade from HTTP to the WebSocket protocol (RFC 6455) | Repeated ordinary HTTP requests |
| Browser API | `EventSource` | `WebSocket` | `fetch()` in a loop |
| Message format | UTF-8 text, `data:` lines | Text or binary frames | Whatever the response body is |
| Auto-reconnect | Built in, with `Last-Event-ID` and `retry:` | None; you write it | You write the loop |
| Request headers | GET only, no custom headers with `EventSource` | Handshake headers fixed by the browser; subprotocol via `Sec-WebSocket-Protocol` | Full control |
| Over HTTP/2 | Multiplexed on one connection | Only with RFC 8441, otherwise a separate HTTP/1.1 connection | Multiplexed |
| Proxy/CDN friendliness | Good once buffering is off | Needs Upgrade support (HTTP/1.1) or extended CONNECT (HTTP/2) | Best |
| Cost per client | One long-held response | One long-held connection | A request per event batch |

## Which One Should I Use

**Notifications, progress bars, dashboards, log tail, LLM token streaming.** SSE. Data goes one way, and the rest of the app (auth, rate limiting, logs, retries) already speaks HTTP.

**Chat, multiplayer state, collaborative editing, anything the client sends many times per second.** WebSockets.

**Binary payloads (audio, game frames).** WebSockets. SSE is UTF-8 text only.

**Strict corporate proxies, or you need to ship tomorrow with only a plain API.** Long polling: the client sends a request, the server holds it until there is data or about 25 to 30 seconds pass, then the client immediately asks again.

## SSE On The Wire

```http
GET /api/events HTTP/1.1
Host: app.example.com
Accept: text/event-stream
Cache-Control: no-cache
```

```http
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no

: heartbeat

id: 1041
event: order.updated
data: {"orderId": "ord_5521", "status": "shipped"}

```

Each event ends with a blank line. `id:` sets the last event ID, `event:` names it for `addEventListener`, `retry:` sets the reconnect delay in milliseconds, and lines starting with `:` are comments, useful as heartbeats so idle proxies do not close the connection.

After a network drop the browser reconnects on its own and says where it stopped:

```http
GET /api/events HTTP/1.1
Host: app.example.com
Accept: text/event-stream
Last-Event-ID: 1041
```

Your server has to retain recent events by id to replay them. A response with status 204 tells `EventSource` to stop reconnecting, and any non-200 status or wrong content type makes the connection fail.

```javascript
const es = new EventSource('/api/events', { withCredentials: true })
es.addEventListener('order.updated', (e) => console.log(JSON.parse(e.data)))
es.onerror = () => console.log('reconnecting...')
```

## The 6-Connection Limit And HTTP/2

Over HTTP/1.1, browsers cap open connections at about six per host, and each `EventSource` occupies one for its whole life. Open six tabs of the same app and the seventh cannot load anything from that origin. MDN notes the limit is per browser and per domain, and that it is marked "Won't fix" in Chrome and Firefox. HTTP/2 removes the problem because streams share one connection: the cap becomes the negotiated concurrent-streams limit, which defaults to 100. See [HTTP/1.1 vs HTTP/2](https://howhttpworks.com/compare/http1-vs-http2). Browsers only use HTTP/2 over TLS, so confirm with `curl --http2 -I https://...` that your edge negotiates it.

## Proxy Buffering: Why Streams Arrive In Bursts

nginx buffers upstream responses by default (`proxy_buffering on`), which holds SSE events until a buffer fills or the response ends. Either turn it off for the stream, or let the application opt out per response with `X-Accel-Buffering: no`:

```nginx
location /api/events {
    proxy_pass http://app_backend;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_cache off;
    gzip off;
    proxy_read_timeout 1h;
}
```

`proxy_read_timeout` defaults to 60 seconds and counts time between reads, so a quiet stream dies at 60s with a 504 unless you send heartbeats or raise it. `proxy_set_header Connection ""` with `proxy_http_version 1.1` keeps the upstream connection persistent. Clearing it avoids sending `Connection: close`.

If you set `proxy_ignore_headers X-Accel-Buffering`, the header stops working. CDNs and other load balancers have their own buffering switches, and a compressing layer will also hold the stream back until it flushes.

## WebSockets On The Wire

HTTP/1.1 handshake:

```http
GET /socket HTTP/1.1
Host: app.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://app.example.com
```

```http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
```

After the 101, the connection stops being HTTP and carries WebSocket frames. See [101 Switching Protocols](https://howhttpworks.com/status-codes/101), [Upgrade](https://howhttpworks.com/headers/upgrade), [Sec-WebSocket-Key](https://howhttpworks.com/headers/sec-websocket-key) and [Sec-WebSocket-Accept](https://howhttpworks.com/headers/sec-websocket-accept).

Behind nginx you must forward the hop-by-hop headers, because `Upgrade` and `Connection` are not passed through automatically:

```nginx
location /socket {
    proxy_pass http://app_backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 1h;
}
```

**Over HTTP/2 (RFC 8441).** There is no `Upgrade` in HTTP/2. A server advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL`, and the client sends an extended CONNECT request on a new stream with `:protocol = websocket` plus `:scheme`, `:path` and `:authority`. A 200 response turns that stream into the WebSocket, so it shares the connection with normal requests. If a proxy does not support it, clients fall back to a separate HTTP/1.1 connection.

**Origin is your job.** Browsers send an `Origin` header on the handshake but WebSockets are not subject to CORS, so any page can open a socket to your server using the visitor's cookies. Validate `Origin` on the server, or authenticate with a token that a cross-site page cannot obtain.

See [WebSockets over HTTP](https://howhttpworks.com/guides/websockets-over-http) for the handshake calculation, nginx configuration, authentication and close codes.

## Long Polling In Short

```http
GET /api/poll?since=1041 HTTP/1.1
Host: app.example.com
```

The server holds the request until an event exists, then answers 200 with the event, or 204 on timeout. The client reconnects immediately. It works through almost any proxy and needs no special server support, but each cycle repeats headers and risks missing events between requests unless you track a cursor like `since`.

## Common Mistakes

**Choosing WebSockets for a one-way feed.** You inherit custom reconnect logic, sticky routing and an `Origin` check, and gain nothing over SSE.

**SSE through nginx with defaults.** The browser shows a spinner and nothing arrives. Turn off buffering for the location or send `X-Accel-Buffering: no`.

**No heartbeats.** Idle timeouts at nginx (`proxy_read_timeout`, 60s by default), load balancers and NATs silently drop quiet connections. Send an SSE comment or a WebSocket ping every 15 to 30 seconds.

**Ignoring reconnects.** Mobile networks drop connections constantly. Use event ids with SSE, and for WebSockets add backoff with jitter plus a "resume from sequence N" message so reconnecting clients do not stampede.

**Trying to send an `Authorization` header with `EventSource` or `new WebSocket(...)`.** Neither API allows it. Use cookies, a short-lived one-time ticket in the URL, or the subprotocol header.

**Running thousands of connections on a thread-per-request server.** Every open stream pins a thread. Use an async runtime or event loop, and check file-descriptor limits (`ulimit -n`, nginx `worker_connections`).

## FAQ

### Should I use SSE or WebSockets?

If data flows mostly from server to browser (notifications, progress, live feeds, LLM token streams), SSE is simpler: it is ordinary HTTP, works with standard auth and logging, and the browser reconnects automatically. Choose WebSockets when the client also sends frequent messages (chat, multiplayer, collaborative editing) or you need binary frames.

### What is the 6-connection limit for SSE?

Over HTTP/1.1, browsers allow about six open connections per host, and every EventSource holds one open, so a handful of tabs can exhaust it. MDN notes this is per browser and domain and marked "Won't fix" in Chrome and Firefox. Over HTTP/2 the limit becomes the negotiated number of concurrent streams, which defaults to 100, so serve SSE over HTTP/2 (or HTTP/3).

### Why does my SSE stream arrive in bursts or only when it closes?

A proxy is buffering the response. For nginx, set proxy_buffering off for that location or have the app send the response header X-Accel-Buffering: no. Also disable gzip for text/event-stream, and check that any CDN or load balancer in front of nginx is not buffering either.

### How does SSE reconnect and resume without losing events?

The server tags events with an id: field. If the connection drops, EventSource reconnects automatically and sends a Last-Event-ID request header with the last id it saw, and the server replays whatever came after. The server can also set the delay with a retry: field in milliseconds. Resumption only works if you keep recent events somewhere to replay from.

### Do WebSockets work over HTTP/2?

Yes, through RFC 8441. The server advertises SETTINGS_ENABLE_CONNECT_PROTOCOL, and the client opens a stream with an extended CONNECT request carrying :protocol = websocket; a 200 response turns that single stream into the WebSocket. Over HTTP/1.1 the classic handshake is a GET with Upgrade: websocket answered by 101 Switching Protocols. Support in servers and proxies is uneven, so many deployments still use HTTP/1.1 for WebSockets.

### Can EventSource send an Authorization header?

No. The browser EventSource API only issues GET requests and cannot set custom headers. Use cookies (with withCredentials for cross-origin), a short-lived token in the URL, or a fetch()-based streaming client library that reads the same text/event-stream format.

## References

- [MDN Web Docs: Using server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)
- [MDN Web Docs: EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
- [MDN Web Docs: WebSockets API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)
- [HTML Standard: Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html)
- [RFC 6455: The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455)
- [RFC 8441: Bootstrapping WebSockets with HTTP/2](https://www.rfc-editor.org/rfc/rfc8441)
- [nginx: ngx_http_proxy_module](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
