Portfolio/Writing/SSE vs WebSockets (and polling): picking a real-time transport on the front end

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 EventSource browser 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#

SSEWebSockets
DirectionServer to client onlyBidirectional
ProtocolPlain HTTP (1.1 or 2)Upgrade to ws/wss, a separate protocol
Browser APIEventSource, tiny, does a lot for youWebSocket, tiny, you build everything on top
ReconnectAutomaticYou implement it
Resume after a dropLast-Event-ID header, server can replay missed eventsYou design it
PayloadUTF-8 text, framed as data: / event: / id:Text or binary frames
Custom auth headers from the browserNot with native EventSourceNot on the handshake either
InfraOrdinary HTTP, but buffering proxies can break streamingNeeds ws-aware load balancers, sometimes sticky sessions
HTTP/1.1 connection limit~6 per domain per browser; HTTP/2 multiplexing removes thisOne connection, not subject to the 6 limit
Scaling modelA held HTTP connection per clientA 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 with stale-while-revalidate at 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.
js
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.
ts
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-source use fetch instead of native EventSource, 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

  • EventSource retries automatically, and the server can hint the delay with a retry: 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 EventSource will 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 onclose promptly 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#

ProjectPickWhy
Chat / messagingWebSocketsFrequent client sends, low latency, presence
Collaborative document editorWebSocketsBidirectional ops, cursors, presence
Multiplayer gameWebSockets (+ WebTransport later)High frequency, often binary, client input
LLM chat streamingSSEServer streams tokens, prompts go back as normal POSTs
Notifications bellSSEServer to client only, wants auto-reconnect
Live feed / timeline updatesSSEOne-directional, replay on reconnect is nice
Long export / import progress barSSEOne job, one stream, then done
CI/CD pipeline status pageSSE or pollingLow frequency, staleness is fine
Read-only price / score tickerSSEPure server push; use WS if the client also acts
"New posts available" badgePollingSeconds-stale is fine, zero infra
IoT dashboard, ~1 update/minPolling or SSEDepends on how many clients and how tight the latency need is
Shared cursors / presenceWebSocketsConstant small bidirectional messages
Summary
Polling is the default when a few seconds of staleness is acceptable and you want no new infrastructure. SSE is the answer for server-to-client streams (notifications, feeds, job progress, LLM tokens): plain HTTP, automatic reconnect and replay, text only. WebSockets are for genuinely bidirectional, low-latency, or binary traffic (chat, collaboration, games, presence), and you own the reconnect, heartbeat and auth flow. Neither SSE nor a WebSocket handshake can carry a custom auth header from the browser, so plan for a cookie or a short-lived query token. If the client does not need to push, choose SSE with a polling fallback and stop there.

Next up
Business logic belongs on the backend, and the front end stays dumb

The front end is not dumb, it is not authoritative. Rules that money, security or correctness depend on live on the server and are enforced there. The server sends decisions, the client renders them, and any client-side copy of a rule is optional decoration.

Read next →