Skip to content
All writing
11 min readAIWeb DevNext.jsArchitecture

SSE vs WebSocket vs WebTransport: How to Choose in 2026

SSE, WebSocket, or WebTransport? A practical 2026 guide to picking a real-time web protocol, with a Next.js example streaming LLM tokens over Server-Sent Events.

A few weeks ago I shipped Pagewise, a tool that takes any public URL and streams back an AI summary token by token. The streaming is the whole experience. Watching the summary type itself out feels alive in a way that a spinner followed by a wall of text never does.

To build that, I had to answer a question that sounds simple and isn't: how do you get the server to push data to the browser in real time?

There are three real answers in 2026: Server-Sent Events (SSE), WebSocket, and WebTransport. They are not interchangeable, and picking the wrong one means either fighting your infrastructure or building machinery you never needed. Here is what each one is for, where it fits, and why I landed on SSE.

(In my first post on this topic I called WebTransport a "fingers crossed" bet on the future. That changed in March. More on that below.)

The short answer: Use SSE when only the server needs to push data, such as streaming AI responses, live dashboards, and notifications. Use WebSocket when the client and server both send messages constantly, such as chat, multiplayer, and collaborative editing. Use WebTransport, supported in every major browser since March 2026, when you need unreliable low-latency datagrams or QUIC's resilience on shaky networks, and always pair it with a WebSocket fallback.

Start with one question: who's doing the talking?

Most real-time decisions come down to direction.

  • If only the server pushes and the client just listens, you want the simplest thing that streams. That is SSE.
  • If both sides send messages constantly, you want a two-way channel. That is WebSocket.
  • If you need two-way plus unreliable, low-latency data (or you are on flaky mobile networks), that is where WebTransport comes in.

Worth saying plainly: a smaller share of features truly need bidirectional communication than most of us assume. "Real-time" gets used as a synonym for "WebSocket", but a lot of real-time problems (notifications, dashboards, progress bars, and yes, LLM output) only flow one way. Naming the direction first saves you from over-building.

What is SSE (Server-Sent Events)?

Server-Sent Events (SSE) is a one-way channel that lets a server push a stream of text updates to the browser over a single, long-lived HTTP connection. It is the boring-in-a-good-way option, running over plain HTTP. The client opens a normal GET request, the server responds with Content-Type: text/event-stream and keeps the connection open, pushing chunks as they are ready. No upgrade, no special port. Any proxy, load balancer, or CDN that already understands HTTP understands SSE.

On the browser, the entire client side is the EventSource API:

js
const events = new EventSource("/api/summarize");

events.addEventListener("token", (e) => {
  appendToSummary(JSON.parse(e.data).text);
});

The wire format is just UTF-8 text. Each message is a small block of lines ending in a blank line:

yaml
event: token
data: {"text": "Hello"}
id: 42

Four fields carry the weight: data (the payload), event (the event name), id (the last-seen event ID), and retry (the reconnect delay in milliseconds).

What I like about it:

  • Reconnection is free. If the connection drops, the browser reconnects on its own (after about three seconds by default) and sends a Last-Event-ID header with the last ID it saw, so the server can resume exactly where it left off. With raw WebSocket, all of that is your job.
  • It is about as simple as real-time gets. No handshake, no protocol of your own, no sticky sessions.
  • It works with the boring infrastructure you already have.

What to watch out for:

  • Text only. SSE carries UTF-8. You can send binary by base64-encoding it, but that adds roughly 33% to the payload, so it is the wrong tool for binary throughput.
  • EventSource is GET-only and cannot set custom headers, so no Authorization: Bearer. You work around it with cookies, or you skip EventSource and read the stream yourself with fetch() and a ReadableStream reader (which is what most AI SDKs do, trading the free reconnection for the ability to send headers).
  • On HTTP/1.1, browsers cap you at six connections per domain, and every open stream eats one slot, which gets rough across multiple tabs. HTTP/2 multiplexes everything over one connection and the limit disappears. On a modern host you are almost certainly fine.
  • The classic deployment bug: a proxy buffering your stream so tokens arrive in one dump instead of one at a time. If you see that, disable buffering (for Nginx, the X-Accel-Buffering: no header).

SSE connection lifecycle: a client opens an EventSource connection, the server responds with text/event-stream, Cache-Control no-cache, and keep-alive headers, then streams event and data messages until the connection closes.

What is WebSocket, and how is it different from SSE?

WebSocket is a protocol that holds a single TCP connection open as a full-duplex channel, so the client and server can both send messages at any time. When the client also needs to push constantly, this is the answer. It starts as an HTTP request with an Upgrade header, the server replies 101 Switching Protocols, and from that point the connection is full-duplex: both sides send whenever they want, with no request and response pairing. It speaks ws:// and wss:// (the TLS version, the one you should use), and it carries binary natively, no base64 tax.

The cost is everything SSE gives you for free. WebSocket has no built-in reconnection and no resume. You implement reconnection (usually exponential backoff), you track sequence numbers if you need to replay missed messages, and you run your own heartbeats (ping and pong frames) to notice dead connections. All of that is doable, it is just work you are signing up for.

Reach for WebSocket when the interaction is two-way and high-frequency: chat, multiplayer games, collaborative editing (think live cursors in a shared doc), and trading terminals. If you find yourself adding WebSocket and then only ever sending from the server, that is a sign SSE was the better fit.

What is WebTransport, and is it ready in 2026?

WebTransport is a browser API for low-latency, two-way communication over HTTP/3 and QUIC, supporting both reliable streams and unreliable datagrams. It is the newest of the three, and the one whose status just changed. Because QUIC sits on UDP, it gives you things the TCP-based protocols cannot:

  • Both reliable, ordered streams (like WebSocket) and unreliable datagrams (like UDP), so you choose per message. A player position update that is already stale by the time it arrives should be dropped, not retransmitted.
  • No head-of-line blocking. On TCP, one lost packet stalls everything queued behind it. QUIC multiplexes independent streams, so a loss on one does not freeze the others.
  • Connection migration. Switch from Wi-Fi to cellular mid-session and the connection survives, because QUIC identifies it by a connection ID rather than the IP and port pairing that WebSocket depends on.

The 2026 update: WebTransport reached Baseline browser support when Safari 26.4 shipped it on 24 March 2026. Chrome, Edge, Firefox, and Opera had it for a while; Safari was the holdout that kept it out of cross-browser production, and that is now resolved. Apple positioned it plainly as a modern alternative to WebSocket for low-latency use cases like multiplayer and live collaboration.

It is not a free lunch yet, though:

  • The specs are still drafts at both the W3C and the IETF, so breaking changes can land, and server libraries sometimes lag the browser.
  • It rides UDP on port 443, which plenty of corporate and public networks block. If the handshake fails there, you need a WebSocket fallback. In practice you ship WebTransport with a safety net, not on its own.
  • The server tooling is younger than the WebSocket ecosystem.

Track it, prototype with it if you need datagrams or connection migration, and keep WebSocket as the dependable default for now.

SSE vs WebSocket vs WebTransport: a comparison table

SSEWebSocketWebTransport
DirectionServer to clientTwo-wayTwo-way
TransportPlain HTTPTCP (after upgrade)HTTP/3 and QUIC (UDP)
DataText (base64 for binary)Text and binaryBinary streams and datagrams
ReconnectAutomatic, with resumeBuild it yourselfBuild it yourself
InfrastructureSimplestNeeds upgrade-aware proxiesNeeds UDP/QUIC and a fallback
Browser supportUniversal since 2011UniversalBaseline since March 2026
Best forLLM streaming, dashboards, notificationsChat, multiplayer, collaborationLow-latency media, gaming, telemetry

Rules of thumb I use:

  • Only the server pushes, the client listens: SSE.
  • Both sides push, a lot: WebSocket.
  • You need unreliable datagrams, stream multiplexing without head-of-line blocking, or survival across a network change: WebTransport, with a WebSocket fallback.

Why I chose SSE for Pagewise (streaming LLM tokens)

Run Pagewise through that filter and the answer is immediate. The server streams a summary; the client just renders it. The data is text (markdown tokens). It needs to survive a flaky connection without losing its place. And it runs on serverless.

That is the SSE column, top to bottom.

There is a second reason that sealed it: the entire LLM ecosystem already streams this way. OpenAI and Anthropic both deliver tokens as text/event-stream (OpenAI sends data: lines ending in a data: [DONE] marker; Anthropic sends named events like content_block_delta). The Vercel AI SDK standardized on SSE for its streaming protocol as of version 5. Choosing SSE meant swimming with the current instead of against it.

How to implement SSE in a Next.js route handler

Here is the shape of it in Pagewise, a Next.js route handler that streams Claude's output as typed events (trimmed here to the streaming essentials):

ts
// app/api/summarize/route.ts
export async function POST(req: Request) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    start(controller) {
      const send = (event: string, data: unknown) =>
        controller.enqueue(
          encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
        );

      // Kick the work off in the background so chunks flush as they arrive,
      // instead of buffering the whole response until the handler returns.
      (async () => {
        try {
          send("metadata", { title, faviconUrl });

          for await (const token of streamFromClaude(url)) {
            send("token", { text: token });
          }

          send("done", { sessionId, wordCount });
        } catch {
          send("error", { code: "LLM_FAILURE", message: "Generation failed" });
        } finally {
          controller.close();
        }
      })();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Connection": "keep-alive",
    },
  });
}

Two things in there are the difference between "it streams" and "it dumps everything at the end":

  1. It is a route handler, not a server action. Server actions are built for mutations and cannot stream an HTTP response incrementally, so streaming has to live in a route.
  2. The producing loop runs in the background inside start(). If you await the whole generation before returning the Response, the framework buffers it, and your user gets the spinner-then-wall-of-text experience you were trying to avoid.

The typed events (metadata, token, done, and error) map cleanly onto the client, where each one gets its own handler. And because this is serverless, the one number you cannot ignore is the function's maximum duration. A long generation can outlast it, so set maxDuration deliberately, and if your responses can run very long, reach for resumable streams rather than hoping the function stays alive.

Frequently asked questions

Is SSE better than WebSocket?

Neither is better in the abstract; they solve different problems. SSE is the simpler choice when only the server pushes data: notifications, dashboards, and LLM token streaming. WebSocket is the right tool when the client and server both send messages continuously, like chat or multiplayer.

Does SSE work with HTTP/2?

Yes, and it is better there. On HTTP/1.1 a browser allows only six connections per domain, and each open SSE stream uses one. HTTP/2 multiplexes many streams over a single connection, so that limit effectively disappears. Most modern hosts serve HTTP/2 by default.

Can SSE send binary data?

Not directly. An SSE stream is UTF-8 text. You can base64-encode binary to send it, but that inflates the payload by about a third, so for real binary throughput WebSocket or WebTransport is the better fit.

What does the Vercel AI SDK use for streaming?

Server-Sent Events. As of AI SDK 5, Vercel standardized its server-to-client streaming on SSE, which is also how the OpenAI and Anthropic APIs deliver tokens. If you are streaming LLM output, you are almost certainly using SSE under the hood.

Is WebTransport production-ready in 2026?

It is now usable across every major browser: Safari shipped it in version 26.4 on 24 March 2026, joining Chrome, Edge, Firefox, and Opera. The specifications are still drafts, and some networks block its UDP traffic, so ship it with a WebSocket fallback.

Why does my SSE stream arrive all at once instead of streaming?

Usually a proxy or framework is buffering the response. Disable buffering at the proxy (for Nginx, set X-Accel-Buffering: no), and make sure your server flushes each event instead of awaiting the whole response before it returns.

The short version

Real-time on the web is not one decision, it is three, and the first question does most of the work: who needs to push data, and in which direction? Answer that clearly and the protocol usually picks itself.

For anything that looks like streaming an AI response, a live feed, a dashboard, or a progress indicator, SSE is very likely the right call, and it is a lot less work than its reputation suggests. WebSocket earns its keep the moment the client becomes a real sender. And WebTransport, as of this year, is finally a production option rather than a someday one.

I'm curious: has anyone here moved off WebSocket to SSE for an AI feature, or are you already running WebTransport in production? Tell me in the comments.


Further reading