Why This Decision Keeps Coming Back

Every team building a "live" feature hits the same forking path. The notification bell needs to update without a refresh. The job-status page should show progress. The chart should tick. The chat needs to feel real.

The instinct is "we need WebSockets." Sometimes that is right. More often the feature is one-way — server speaks, client listens — and Server-Sent Events would have shipped in two days instead of two weeks. The decision is small enough to skip in design review and large enough to shape the architecture for years.

So worth doing it on purpose.

What Each One Actually Is

A WebSocket is a full-duplex, persistent TCP connection that starts as an HTTP/1.1 upgrade and then stops being HTTP. Once open, both sides can send framed messages at any time, in any order. It is the right primitive for chat, collaborative editing, multiplayer games, and any UI where the client also needs to send messages mid-stream.

Server-Sent Events are plain HTTP. The client opens an EventSource, the server keeps the response open, and writes data: ...\n\n-formatted chunks whenever it has something to say. The connection is one-way: server to client. The browser handles reconnection automatically with a Last-Event-ID header so the server can resume from where it left off. There is no separate protocol — it is just an HTTP response that never ends.

JavaScript
const events = new EventSource('/api/jobs/stream');
events.addEventListener('progress', (e) => {
  const { jobId, percent } = JSON.parse(e.data);
  updateJob(jobId, percent);
});
events.addEventListener('error', () => {
  // EventSource auto-reconnects unless you explicitly close it.
});

That is the whole client. There is no library to install. Compare to a robust WebSocket setup with backoff, heartbeats, and message replay, and the size difference is real.

The Cases Where SSE Is Just Better

If your feature is one-way — notifications, job status, log streams, dashboards, AI token streaming, server-pushed cache invalidations — SSE is almost always the right call. You get:

  1. Automatic reconnection with Last-Event-ID resume support, free, in the browser.
  2. Plain HTTP semantics, which means standard auth (cookies, bearer tokens), standard logging, standard CDN behavior, standard CORS, and standard load balancer support.
  3. No protocol upgrade, which matters in environments where the upgrade is blocked — corporate proxies, some edge providers, some serverless hosts.
  4. Simpler servers. Holding open an HTTP response is something every framework already does. Maintaining a WebSocket pool with rooms and broadcast is a bigger commitment.

The classic example: AI streaming. ChatGPT, Claude, and most LLM APIs stream responses over SSE for exactly these reasons. The client sends one request, the server pushes tokens until done. There is nothing the client needs to say mid-stream that a fresh request can't handle.

A diagram comparing SSE and WebSockets side by side: on the left an SSE flow showing a single HTTP request from client to server with a long stream of small data events flowing back, labeled with EventSource, automatic reconnect, and Last-Event-ID; on the right a WebSocket showing a bidirectional channel with arrows in both directions and labels for ping/pong, send, broadcast, and rooms; with a small comparison table at the bottom listing direction, transport, reconnect, and best fit.
SSE is one lane. WebSockets are a roundabout. Most live UI is one lane.

The Cases Where WebSockets Earn Their Cost

There are real reasons to take on the extra work:

  1. Bidirectional, low-latency messaging. Chat, presence, cursors, multiplayer state.
  2. Binary frames. Audio, video signaling, custom protocols, anything you don't want to base64.
  3. Many small client-to-server messages. Sending a heartbeat or a typing indicator over a fresh HTTP request each time is wasteful.
  4. Subscription multiplexing on a single connection. GraphQL subscriptions, real-time databases (Firestore, Supabase, Convex), message bus clients.

If your feature genuinely needs the client to push messages on the same channel, WebSockets are the right tool and SSE is not a substitute. Don't try to fake bidirectional with SSE plus a parallel POST endpoint — you are reinventing the protocol badly.

For the senior teams I see ship this well, the rule of thumb is: if you can reasonably express the client side as "subscribe and listen," use SSE. The moment you need "subscribe, listen, and reply on the same connection," upgrade to WebSockets.

What The Server Side Actually Looks Like

The reason SSE feels easy is that the server side really is small. Here's a working Express handler that streams job progress to a single client:

JavaScript
app.get('/api/jobs/:id/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering
  res.flushHeaders();

  const send = (event, data) => {
    res.write(`event: ${event}\n`);
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  };

  const onProgress = (p) => send('progress', p);
  jobs.on(`progress:${req.params.id}`, onProgress);

  // Heartbeat every 15s so proxies don't kill the idle connection.
  const ping = setInterval(() => res.write(': ping\n\n'), 15000);

  req.on('close', () => {
    clearInterval(ping);
    jobs.off(`progress:${req.params.id}`, onProgress);
  });
});

Fastify and Hono are basically the same shape — set the headers, write event: and data: lines, clean up on disconnect. The interesting work is upstream: how do you fan-out from your job/event source to N connected clients? In a single-process server, an EventEmitter is enough. In a horizontally scaled deployment, you need a shared bus — Redis pub/sub, NATS, Postgres LISTEN/NOTIFY, or a managed broker. The connection layer doesn't care; the bus is what makes it work across instances.

The WebSocket version of the same feature isn't dramatically more code, but it adds a connection lifecycle (open, message, close, error), a message protocol you have to design, and a room/topic concept if more than one client subscribes. With SSE, the URL is the topic.

Operational Gotchas Worth Knowing Before Production

A short list of things that quietly break SSE in production if nobody is watching:

  1. Reverse proxy buffering. Nginx, by default, buffers proxied HTTP responses. Your events queue up server-side and ship in chunks instead of streaming. The fix is proxy_buffering off; in the location block, or — more commonly — set the X-Accel-Buffering: no response header from the app, which nginx respects per-response. Caddy, Traefik, and most cloud load balancers stream by default, but check yours.
  2. Idle connection timeouts. AWS ALB, Cloudflare, GCP HTTPS load balancers all have idle timeouts (60s, 100s, etc.). If your server has nothing to send for that long, the connection dies. Send a comment heartbeat (: ping\n\n) every 15–30 seconds. The browser ignores it; the proxy resets its timer.
  3. HTTP/1.1 connection caps vs HTTP/2 multiplexing. Already covered below, but worth flagging in the operational checklist — verify HTTP/2 is end-to-end, not just at the edge.
  4. Compression. Don't gzip SSE streams. Most servers buffer until they have enough data to compress, which defeats the entire point. Set Cache-Control: no-cache, no-transform and disable compression for the streaming route.
  5. Client memory leaks. EventSource doesn't auto-close when the page navigates away in some legacy SPA setups. Always call events.close() in your component cleanup.

These are the kinds of issues that pass code review and then page on-call at 3am. Easier to bake them in from day one.

The HTTP/1.1 Six-Connection Trap

The honest caveat for SSE: HTTP/1.1 limits browsers to about six concurrent connections per origin. An open SSE stream takes one of those slots. Open three live tabs of the same app and you've used half the budget; image and API requests have to queue.

HTTP/2 and HTTP/3 multiplex everything over a single connection, so the limit effectively goes away. If your hosting and load balancer support HTTP/2 end-to-end (Vercel, Cloudflare, Fly, modern nginx, ALBs, anything HTTP/2-capable), the trap is largely historical. If you are deploying to a stack that terminates HTTP/2 at the edge but proxies HTTP/1.1 to your origin and your origin holds the connection — measure before assuming.

WebSockets are not subject to the same per-origin limit (different connection class), which is one reason chat-heavy apps gravitated to them historically even when their traffic was effectively one-way.

Auth, CORS, And Why SSE's Plumbing Wins

A subtle SSE advantage worth its own paragraph: it goes through your existing HTTP middleware. The same auth, the same rate limiter, the same logging, the same observability — no extra path. WebSockets traditionally bypass much of this because the upgrade short-circuits the request lifecycle in many frameworks.

The practical pain: an HTTP-only auth token in a cookie works for SSE without a thought. For WebSockets, you have to either authenticate during the upgrade handshake (and many WS libraries make this awkward) or send the token as the first message after open, which means you have a brief unauthenticated period to handle. Subprotocol-based auth is cleaner but requires server cooperation.

If your app already speaks HTTP for everything else, SSE inherits that work. WebSockets ask you to redo it.

Auth In Practice: Cookies For SSE, Tickets For WebSockets

The browser's EventSource API has one annoying quirk: it doesn't support custom headers. You can't set Authorization: Bearer ... on it. But it does send cookies, which is usually fine — most apps already authenticate with an HttpOnly session cookie, and SSE rides on it for free. Same-origin SSE plus a session cookie is one of the cleanest auth stories in the platform.

If you need cross-origin SSE with a token, two workable patterns:

  1. Cookie-based with withCredentials: true. new EventSource('/stream', { withCredentials: true }) sends cookies on cross-origin requests, provided your CORS config returns Access-Control-Allow-Credentials: true and a specific origin (not *).
  2. Token in the query string. Less elegant — tokens leak into server logs and proxy logs — but works. Use a short-lived, single-purpose token, not your main API token. Some teams call this a "stream ticket": the client POSTs to /stream-ticket, gets back a 30-second JWT, and uses it as ?ticket=... on the EventSource URL.

WebSockets have the same custom-headers problem in browsers, and the standard fix is the ticket pattern. Workflow:

JavaScript
// 1. Client asks the API for a short-lived ticket over a normal authenticated request.
const { ticket } = await fetch('/api/ws-ticket', { credentials: 'include' }).then(r => r.json());

// 2. Client opens the WebSocket with the ticket as a query param.
const ws = new WebSocket(`wss://realtime.example.com/?ticket=${ticket}`);

The server validates the ticket on upgrade, swaps it for a session, and rejects the connection if it's missing, expired, or already used. Tickets should be one-shot, expire in seconds, and be issued only to authenticated callers. The WebSocket subprotocol field (Sec-WebSocket-Protocol) is sometimes used as a token-passing channel, and it's slightly cleaner than the query string, but most WS servers and CDNs don't make this convenient — pick whichever your stack supports without ceremony.

The point: don't try to send Authorization from the browser on either protocol. The browser won't let you. Plan for cookies on SSE and tickets on WebSockets, and both stop being awkward.

Where WebTransport Fits

A short note on the new kid. WebTransport is an HTTP/3-based API that gives you bidirectional streams and unreliable datagrams. It is the eventual successor to WebSockets for many use cases — better cancellation semantics, real backpressure, no head-of-line blocking. Browser support has caught up — Chromium has had it since 97, Firefox since 114, Safari since 26.4 — but server-side library support, CDN passthrough, and operational tooling lag the spec. Worth tracking, and worth piloting on a non-critical surface; "every user can use it tomorrow" is not yet true.

A Decision That Holds Up

The framing I keep coming back to:

  1. One-way feature, simple auth, no need for binary? Start with SSE. You can always add a WebSocket later if requirements change.
  2. Chat, collaboration, multiplayer, presence? WebSockets, with socket.io or PartyKit on top so you don't reinvent rooms and reconnection.
  3. AI streaming, log tail, job progress, notifications? SSE every time. The platforms that do this well at scale (OpenAI, Anthropic, Vercel AI SDK) all chose SSE for a reason.
  4. Real-time database client? Use whatever the database ships. Firestore, Supabase Realtime, and Convex made this decision for you, and it's almost always WebSockets under the hood with multiplexed subscriptions.

The mistake is reaching for WebSockets reflexively because they sound more powerful. Power you don't use is complexity you have to maintain. SSE is not a lesser WebSocket — it is a different tool for a different shape of traffic, and most "live UI" features are exactly that shape.

Honest Caveats

A few things SSE will not do, no matter how nicely you ask:

  1. Send anything from the client mid-stream. That's POST.
  2. Carry binary efficiently. Text-only protocol; base64 if you must.
  3. Survive being held by a proxy that buffers HTTP responses. Some old corporate proxies still do this. Disable buffering at your gateway (X-Accel-Buffering: no for nginx) and you're fine.

None of these are dealbreakers for the common use cases. They are just the reasons the protocol stayed simple.

A Workable Mental Model

SSE is a long, polite phone call where only the server talks and the connection auto-redials if it drops. WebSockets are a walkie-talkie that both sides can press. Most live UI features are phone calls dressed up as walkie-talkies because a developer once read a tutorial about WebSockets and never looked at the alternative. Look at the alternative. You will ship faster.