SSE vs WebSockets (and polling): picking a real-time transport on the front end
How Server-Sent Events, WebSockets and plain polling differ from the client side, the auth and reconnection gotchas each one has, and a project-by-project guide for which to reach for.
You need the UI to update without the user hitting refresh. The choices run from "ask again on a timer" to "hold a socket open for the life of the session". Pick too heavy and you are running socket infrastructure you did not need. Pick too light and the UI lags or hammers your API. This is the client-side view of the three options and when each one is right.
The options#
- Polling. Fetch on an interval. Zero new infrastructure, works everywhere.
- Long polling. The server holds the request open until it has data or times out. Mostly legacy now that SSE exists, but still what some fallbacks use.
- Server-Sent Events (SSE). One long-lived HTTP response that the server keeps writing to. One direction, server to client. The
EventSourcebrowser API. Text only. Automatic reconnect and resume are built in. - WebSockets. A full-duplex connection over its own protocol after an HTTP upgrade. Text or binary. You build the message protocol, the reconnect, and the auth flow yourself.
Managed real-time services (Pusher, Ably, Supabase Realtime, Firebase) and GraphQL subscriptions are almost always WebSockets underneath. WebTransport over HTTP/3 exists and is worth knowing about, but few front ends need it yet.
SSE vs WebSockets, side by side#
| SSE | WebSockets | |
|---|---|---|
| Direction | Server to client only | Bidirectional |
| Protocol | Plain HTTP (1.1 or 2) | Upgrade to ws/wss, a separate protocol |
| Browser API | EventSource, tiny, does a lot for you | WebSocket, tiny, you build everything on top |
| Reconnect | Automatic | You implement it |
| Resume after a drop | Last-Event-ID header, server can replay missed events | You design it |
| Payload | UTF-8 text, framed as data: / event: / id: | Text or binary frames |
| Custom auth headers from the browser | Not with native EventSource | Not on the handshake either |
| Infra | Ordinary HTTP, but buffering proxies can break streaming | Needs ws-aware load balancers, sometimes sticky sessions |
| HTTP/1.1 connection limit | ~6 per domain per browser; HTTP/2 multiplexing removes this | One connection, not subject to the 6 limit |
| Scaling model | A held HTTP connection per client | A held socket per client, usually plus a pub/sub fan-out tier |
Polling still has a place#
If the data changes slowly or unpredictably and being a few seconds stale is fine, polling is the right answer and the cheapest one.
- Dashboards, a "new items" badge, CI status, an IoT panel that updates once a minute.
- With TanStack Query:
refetchInterval,refetchOnWindowFocus, and it pairs withstale-while-revalidateat the edge (caching post). - Make it adaptive: back off or stop when the tab is hidden, poll faster right after a user action.
- Jitter the interval so a thousand clients do not all poll on the same second.
The cost is a latency floor equal to the interval, plus requests that often return nothing.
When SSE fits#
- One-directional updates: notifications, a live feed, the progress of a long job, a price ticker you only read, "someone else edited this" nudges.
- Streaming tokens from an LLM. This is why assistant-style UIs use SSE: the server streams text, the client appends it, the client's next prompt is a normal POST.
- Build and deploy logs streamed into a panel.
- You want automatic reconnect and event replay without writing either.
- You want the least infrastructure. It is just an HTTP response.
const es = new EventSource("/api/notifications", { withCredentials: true });
es.addEventListener("notification", (e) => {
addToInbox(JSON.parse(e.data));
});
es.onerror = () => {
// EventSource reconnects on its own. Just reflect the state.
setConnectionState("reconnecting");
};
es.onopen = () => setConnectionState("live");
// EventSource retries forever, even on a 404. Close it yourself when done.
// on unmount / route change: es.close();When WebSockets fit#
- Genuinely bidirectional and low-latency: chat, collaborative editing (cursor positions, presence, CRDT or OT operations), multiplayer games, live drawing, a trading terminal that also places orders.
- Binary payloads: audio or video frames, protobuf or msgpack, packed game state.
- The client sends often enough that a POST per message would be wasteful.
class RealtimeSocket {
private ws?: WebSocket;
private attempt = 0;
private heartbeat?: number;
connect() {
this.ws = new WebSocket("wss://api.example.com/realtime?token=" + getToken());
this.ws.onopen = () => {
this.attempt = 0;
this.send({ type: "subscribe", channels: currentChannels() });
this.heartbeat = window.setInterval(() => this.send({ type: "ping" }), 25000);
};
this.ws.onclose = () => {
clearInterval(this.heartbeat);
const delay = Math.min(1000 * 2 ** this.attempt++, 30000) + Math.random() * 1000;
setTimeout(() => this.connect(), delay); // backoff + jitter
};
this.ws.onmessage = (e) => handleMessage(JSON.parse(e.data));
}
send(msg: unknown) {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
}
}The auth gotcha#
The browser will not let you set arbitrary headers on either an EventSource or a WebSocket handshake. So Authorization: Bearer is out for both. The usual ways around it:
- Cookies. Both send cookies if the connection is same-site (SSE needs
withCredentials: true). An httpOnly session cookie is the cleanest option when you have one. - A short-lived token in the query string. Works for both. It lands in server access logs and proxy logs, so mint a token that expires in minutes and is scoped to this connection.
- A fetch-based SSE client. Libraries like
@microsoft/fetch-event-sourceusefetchinstead of nativeEventSource, so you can send headers and a POST body, at the cost of implementing reconnect yourself. - An auth message right after connect. WebSocket pattern: connect, immediately send the token as the first message, and the server closes the socket if it does not arrive or is invalid within a second or two.
Reconnection and resilience, from the client#
SSE
EventSourceretries automatically, and the server can hint the delay with aretry:field.- You still handle: dedupe events after a replay, resubscribe app state, show a "reconnecting" indicator, and stop retrying when the resource is gone, because
EventSourcewill hammer a 404 forever.
WebSockets
- Build reconnect with exponential backoff plus jitter, and a retry cap.
- Send a heartbeat ping every 20 to 30 seconds. Browsers do not always fire
onclosepromptly when a connection dies silently, so the heartbeat is how you find out. - Resubscribe to channels on every reconnect. The server does not remember you.
- Decide what happens to outbound messages while disconnected: queue them, or drop them and let the resubscribe resync state.
Both
- Pause or close when
document.hidden, resume on focus. - Close on component unmount and on navigation.
- Watch for the back/forward cache freezing and resuming the page.
A progressive strategy#
If you do not need the client to push, skip straight to SSE with a polling fallback. That covers almost everything and stays simple. Reach for the full "WebSocket, else SSE, else long-poll" ladder (what socket.io does) only when you genuinely need bidirectional and have to support hostile proxies.
Which one, by project#
| Project | Pick | Why |
|---|---|---|
| Chat / messaging | WebSockets | Frequent client sends, low latency, presence |
| Collaborative document editor | WebSockets | Bidirectional ops, cursors, presence |
| Multiplayer game | WebSockets (+ WebTransport later) | High frequency, often binary, client input |
| LLM chat streaming | SSE | Server streams tokens, prompts go back as normal POSTs |
| Notifications bell | SSE | Server to client only, wants auto-reconnect |
| Live feed / timeline updates | SSE | One-directional, replay on reconnect is nice |
| Long export / import progress bar | SSE | One job, one stream, then done |
| CI/CD pipeline status page | SSE or polling | Low frequency, staleness is fine |
| Read-only price / score ticker | SSE | Pure server push; use WS if the client also acts |
| "New posts available" badge | Polling | Seconds-stale is fine, zero infra |
| IoT dashboard, ~1 update/min | Polling or SSE | Depends on how many clients and how tight the latency need is |
| Shared cursors / presence | WebSockets | Constant small bidirectional messages |