Build multi-modal AI applications using our new open-source Vision AI SDK .

WebSocket vs. Server-Sent Events: Key Differences

New
19 min read
Raymond F
Raymond F
Published April 22, 2026

TLDR;

  • WebSocket exits HTTP entirely for a full-duplex TCP channel. SSE stays inside a standard HTTP response that never closes.
  • A six-connection-per-origin browser limit made SSE impractical under HTTP/1.1. HTTP/2 multiplexing killed that constraint, and LLM token streaming has since made SSE the default transport for most major AI SDKs.
  • SSE is the simpler default for server-push use cases: automatic reconnection, CDN support, no sticky sessions. WebSocket is the right call when both sides need to send data at high frequency on the same connection.

For years, WebSocket has been the default answer to real-time web communication. Need live data? Use WebSocket. Building a dashboard? WebSocket. It was the only browser-native option with persistent, low-latency connections.

Except that wasn't quite true. Hidden in the HTML5 spec was another browser-native persistent connection: Server-Sent Events. SSE shipped in every major browser by 2011, with built-in reconnection included.

So why did WebSocket dominate for over a decade, and why is that changing now?

WebSocket vs. Server-Sent Events

WebSocket and Server-Sent Events solve the same core problem: sometimes, you need to get data from a server to a browser without the client having to ask for it over and over. But they work in fundamentally different ways.

  • With WebSocket, the client and server start with a normal HTTP request, then "upgrade" the connection into a separate protocol. Once the upgrade completes, HTTP is gone. What's left is a persistent, full-duplex TCP channel where both sides can send messages whenever they want, on the same connection, with no request-response pairing.
  • Server-Sent Events (SSE) never leave HTTP. The client sends a GET request, the server responds with Content-Type: text/event-stream, and then the response just... never ends. The server keeps writing events into that open response for as long as the connection lasts. It's strictly one-way: the server pushes to the client. If the client needs to send something back, it makes a separate HTTP request.

SSE was standardized as part of the HTML5 spec in 2006, three years before the first WebSocket draft. It shipped in every major browser by 2011. On paper, it should have been the obvious choice for any server-push use case: simpler to implement, compatible with existing HTTP infrastructure, and it came with built-in reconnection and message replay for free. In practice, a single limitation overshadowed all of that.

Under HTTP/1.1, browsers enforce six concurrent connections per origin. Each SSE stream permanently occupies one of those slots, because the HTTP response never completes. Open six SSE connections and every other request to that domain (API calls, images, stylesheets, additional SSE streams, anything) queues indefinitely. WebSocket connections, having exited HTTP to their own channel, had a structural advantage. Combined with SSE's lack of bidirectional communication, this made WebSocket the default recommendation for over a decade.

What changed was a combination of infrastructure, AI, and framework adoption: :

  • HTTP/2 multiplexing eliminated SSE's connection limit. All SSE streams now share a single TCP connection as logical multiplexed streams, with a default maximum of approximately 100 concurrent streams. The six-connection cap was gone.
  • AI/LLM token streaming created massive demand for exactly the pattern SSE was designed for: a client sends one request, and the server streams back a long sequence of text events. OpenAI, Anthropic, Google Gemini, and virtually every LLM provider adopted SSE for their streaming APIs.
  • Framework adoption codified the shift. tRPC v11 added SSE subscriptions and explicitly recommends them as the default. GraphQL Yoga uses SSE as its default subscription transport. The Vercel AI SDK, OpenAI's SDK, and Anthropic's SDK all consume SSE under the hood.

The conventional wisdom has inverted from “use WebSocket unless you can't” to “start with SSE unless you specifically need bidirectional communication.”

How Each Protocol Works

Both technologies begin life as HTTP requests. They diverge immediately after.

WebSocket

The client sends a normal-looking HTTP/1.1 GET, but with a couple of special headers:

  • Upgrade: websocket tells the server the client wants to switch protocols.
  • Sec-WebSocket-Key is a random Base64-encoded value the client generates for the handshake. The server sends a hashed version result back to prove the server actually understands WebSocket and didn't just accidentally return a 101.

The server responds with 101 Switching Protocols. At that point, HTTP is gone. . The connection switches over to WebSocket's own protocol running directly on TCP.

From there, it's full-duplex. Either side can send a message at any time, no waiting for a response, no taking turns. WebSocket defines six frame opcodes that cover the basics, such as text and binary.

One thing worth knowing: frames from different messages can't be interleaved on a single connection, which means a large message in transit blocks everything behind it.

Server-Sent Events

SSE also starts with a plain GET request. The server responds with 200 OK, and Content-Type: text/event-stream, and then the response body stays open. Events get written into that stream as they happen, using a simple text format with four field types:

  • data: for the payload
  • id: for sequencing
  • event: for named event types
  • retry: to tell the browser how long to wait before reconnecting.

Lines starting with: are comments, typically used as keepalive heartbeats.

As SSE is strictly server-to-client, the client's half of the TCP connection sits idle after the initial request. If the client needs to send something, it makes a separate HTTP request.

The rest of this article walks through the six dimensions where the two protocols differ most:

  1. Directionality. WebSocket is full-duplex; SSE is server-to-client only.
  2. Connection lifecycle. WebSocket has a formal upgrade handshake and close codes; SSE is a standard HTTP response that just stays open.
  3. Reconnection. SSE reconnects automatically with message replay built in; WebSocket makes you do it yourself.
  4. Binary data. WebSocket sends binary natively; SSE is text-only, so binary payloads need Base64 encoding.
  5. Auth and custom headers. Neither protocol's browser API lets you set arbitrary headers cleanly, but the workarounds differ.
  6. Protocol overhead. WebSocket has lighter per-message framing; SSE gets HTTP-level compression for free.

1. Directionality: Full-Duplex vs. Server Push

This is the fundamental difference, and it determines which protocol fits your use case.

WebSocket gives you a single connection where both sides send messages freely. A chat message, a typing indicator, a cursor position update: the client sends it on the same connection that delivers incoming messages. One connection, two-way traffic.

javascript
1
2
3
4
5
6
7
8
9
10
11
// WebSocket: send and receive on the same connection const ws = new WebSocket('wss://example.com/chat'); ws.onmessage = (e) => { const data = JSON.parse(e.data); displayMessage(data); }; function sendMessage(text) { ws.send(JSON.stringify({ type: 'message', text })); }

SSE gives you a one-way pipe from server to client. If the client needs to send data, it makes a separate HTTP request. EventSource has no .send() method.

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SSE: receive via EventSource, send via separate POST const es = new EventSource('/events'); es.onmessage = (e) => { const data = JSON.parse(e.data); displayMessage(data); }; function sendMessage(text) { // Completely separate HTTP request fetch('/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'message', text }), }); }

For many applications, this "limitation" is actually fine. Dashboards, notifications, live feeds, LLM token streaming: the data flows primarily server-to-client. The client sends an occasional action via a normal REST endpoint. SSE + REST cleanly covers this pattern, and the REST endpoint benefits from standard HTTP features: caching, authentication middleware, request validation, and rate limiting.

Where it breaks down: applications that need true bidirectional communication on a single channel. Trying to simulate it with SSE + POST introduces latency and complexity that WebSocket avoids.

  • Chat: every participant sends and receives messages continuously, with typing indicators and presence flowing in both directions.
  • Collaborative editing: cursor positions, keystrokes, and document operations stream from every connected client simultaneously.
  • Multiplayer gaming: player inputs and game state updates need to flow both ways with minimal latency.
  • Financial trading: rapid order submission and real-time price feeds on the same connection, where milliseconds matter.

2. Connection Lifecycle

WebSocket tracks connection state through four readyState values:

  • CONNECTING (0): the upgrade handshake is in flight.
  • OPEN (1): the upgrade succeeded, and messages can flow.
  • CLOSING (2): a close frame has been sent, and we're waiting for the other side.
  • CLOSED (3): the connection is fully torn down.

The connection starts with the HTTP upgrade handshake, exits HTTP entirely when it gets back 101 Switching Protocols, and settles into a persistent TCP connection running WebSocket's own binary framing.

Closing is its own little handshake. Either side can send a Close frame with a status code and an optional reason string, and the other side sends one back before the TCP connection actually terminates. The standard close codes cover common cases: 1000 is a normal closure, 1001 means the endpoint is going away (e.g., tab closing or server shutdown), and 1006 indicates the connection died abnormally without a proper close frame. If you need your own, applications get the 4000-4999 range to play with.

Keepalives are built into the protocol. The server sends a Ping frame (opcode 0x9), the client automatically replies with a Pong frame (opcode 0xA), and if no Pong comes back within some timeout, the server knows it's talking to a ghost.

javascript
1
2
3
4
5
6
7
// WebSocket close: formal handshake with code and reason ws.close(1000, 'User navigated away'); ws.onclose = (e) => { console.log(`code=${e.code} reason="${e.reason}" clean=${e.wasClean}`); // code=1000 reason="User navigated away" clean=true };

SSE is simpler. Just stand HTTP and no need for a close handshake. Three readyState values instead of four:

  • CONNECTING (0): the HTTP request is in flight.
  • OPEN (1): the stream is live.
  • CLOSED (2): the connection is gone.

There's no CLOSING state because there's no close handshake to wait for. Calling eventSource.close() just drops the HTTP connection. No status code, no reason string, and no acknowledgment from the server. On the server side, you get a TCP close event, and that's it. If you want to know why the client left, you'll have to infer it from other signals.

javascript
1
2
3
4
// SSE close: just drops the connection eventSource.close(); // No close code, no reason, no handshake. // Server sees: req.on('close', () => { /* client gone, no metadata */ });

That sounds like a downside, but it's actually where SSE's simplicity pays off. Because the whole thing is just HTTP, it works transparently with every proxy, CDN, and load balancer in existence. The only configuration you typically need is turning off response buffering in Nginx. WebSocket requires explicit Connection: Upgrade header forwarding and extended read timeouts to keep long-lived connections from getting killed.

PropertyWebSocketSSE
readyState values4 (CONNECTING, OPEN, CLOSING, CLOSED)3 (CONNECTING, OPEN, CLOSED)
HandshakeHTTP 101 Switching ProtocolsHTTP 200 OK
Close mechanismClose frame with code + reasonTCP close (no metadata)
HeartbeatPing/Pong frames (opcode 0x9/0xA)Comment lines (: keepalive\n\n)
Proxy configExplicit upgrade forwarding, extended timeoutsproxy_buffering off
CDN supportMust terminate at originWorks transparently

3. Reconnection

This is SSE's biggest ergonomic advantage, and it's underappreciated. The EventSource API handles reconnection automatically. When the connection drops, the browser waits a configurable interval (default 3 seconds, overridable via the server's retry: field) and reconnects. On reconnection, it sends a Last-Event-ID header set to the id of the last received event, enabling the server to replay missed events.

This provides reliable delivery across reconnections as a protocol-level feature. The developer writes zero lines of reconnection logic.

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Server: send events with IDs and a custom retry interval app.get('/events', (req, res) => { const lastId = req.headers['last-event-id']; // Resume from where the client left off let count = lastId ? parseInt(lastId) : 0; res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', }); res.write('retry: 3000\n\n'); // reconnect after 3 seconds const interval = setInterval(() => { count++; res.write(`id: ${count}\n`); res.write(`data: {"seq": ${count}}\n\n`); }, 1000); req.on('close', () => clearInterval(interval)); }); // Client: literally nothing to do const es = new EventSource('/events'); es.onmessage = (e) => console.log(e.data); // If the connection drops: // 1. Browser waits 3 seconds (from retry: field) // 2. Reconnects with Last-Event-ID header // 3. Server resumes from that ID // 4. No developer code involved

WebSocket provides nothing comparable. The onclose event fires, and the application must implement reconnection logic from scratch: exponential backoff, attempt tracking, and full state resynchronization after reconnecting.

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// WebSocket: manual reconnection with exponential backoff let ws; let backoff = 1000; let attemptCount = 0; function connect() { ws = new WebSocket('wss://example.com/feed'); ws.onopen = () => { backoff = 1000; // reset on success attemptCount = 0; // Must re-subscribe, re-authenticate, re-sync state ws.send(JSON.stringify({ type: 'subscribe', channels: ['prices'] })); }; ws.onclose = (e) => { attemptCount++; console.log(`Reconnecting in ${backoff}ms (attempt #${attemptCount})`); setTimeout(() => { connect(); backoff = Math.min(backoff * 2, 30000); }, backoff); }; ws.onmessage = (e) => handleMessage(JSON.parse(e.data)); } connect();

The difference is stark. The SSE reconnection path is zero lines of application code. The WebSocket reconnection path is a nontrivial state machine that every application using WebSocket must implement, and most get wrong in subtle ways (race conditions during reconnection, duplicate messages, lost subscriptions).

4. Binary Data

WebSocket handles binary natively. Frame opcode 0x2 marks a frame as binary, and the browser just deals with ArrayBuffer, Blob, and TypedArray objects directly. No encoding, no decoding, nothing extra.

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// WebSocket: native binary support const ws = new WebSocket('wss://example.com/data'); ws.binaryType = 'arraybuffer'; // Sending binary const data = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]); ws.send(data.buffer); // Sent as binary frame (opcode 0x2) // Receiving binary ws.onmessage = (e) => { if (e.data instanceof ArrayBuffer) { const view = new DataView(e.data); console.log(`Received ${e.data.byteLength} bytes`); } };
Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

SSE can't do that. The spec is text-only (UTF-8), which means any binary payload has to be Base64-encoded on the way out and decoded on the way in. That's roughly 33% more bytes on the wire, plus the CPU cost of encoding on both ends.

To see what that actually looks like, take a tiny 1x1 red-pixel PNG. The raw file is 68 bytes. Here it is as bytes, with a few of the PNG magic bytes called out:

89 50 4E 47 0D 0A 1A 0A  ← PNG signature ("‰PNG\r\n\x1a\n")
00 00 00 0D 49 48 44 52  ← IHDR chunk start
00 00 00 01 00 00 00 01  ← width=1, height=1
08 02 00 00 00 90 77 53
DE 00 00 00 0C 49 44 41
54 08 D7 63 F8 CF C0 00
00 00 03 00 01 5B B0 1D
39 00 00 00 00 49 45 4E  ← IEND chunk
44 AE 42 60 82

Over WebSocket, you send that buffer directly. 68 bytes on the wire, 68 bytes reconstructed on the other side. Done.

Over SSE, you have to turn those 68 bytes into a UTF-8 string first. Base64 is the standard choice, since it maps any byte sequence to a safe ASCII subset at a predictable 4-chars-per-3-bytes ratio:

javascript
1
2
3
4
5
// Server const imageBuffer = fs.readFileSync('red-pixel.png'); // 68 bytes const base64 = imageBuffer.toString('base64'); // "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" res.write(`data: {"type":"image","base64":"${base64}"}\n\n`);

The 68-byte PNG is now a 92-byte Base64 string, wrapped in JSON, wrapped in an SSE data: frame. The bytes went from binary to text, which is a one-way door, since now the client has to walk them back:

javascript
1
2
3
4
5
6
7
8
9
10
// Client es.onmessage = (e) => { const { base64 } = JSON.parse(e.data); const binary = atob(base64); // decode to binary string const bytes = new Uint8Array(binary.length); // allocate typed array for (let i = 0; i < binary.length; i++) { // copy byte by byte bytes[i] = binary.charCodeAt(i); } const blob = new Blob([bytes], { type: 'image/png' }); };

So you have four steps: (1) parse the JSON, (2) decode the Base64, (3) copy the bytes into a typed array, and (4) wrap it in a Blob. All to get back to what WebSocket would have handed you in one line.

At 68 bytes, the overhead is invisible. But it scales linearly. 10KB of binary becomes 13.6KB. 1MB becomes 1.33MB. If you're shipping images, audio, video, or compact binary wire formats like protobuf, FlatBuffers, or financial market data, that overhead adds up fast, and WebSocket's native binary support is a meaningful win.

For most web applications, though, it just doesn't come up. JSON is text. API responses are text. LLM tokens are text. The binary advantage is real, but it applies to fewer use cases than it might seem.

5. Auth and Custom Headers

Neither protocol's browser API lets you set arbitrary headers cleanly. Both EventSource and WebSocket constructors take a URL and not much else, which means common auth patterns are off the table by default. The workarounds differ, though, and it's worth understanding where each one lands you.

The SSE EventSource limitation

The native EventSource API doesn't let you set custom request headers. That means no Authorization: Bearer <token> header, which is the default auth pattern for most modern APIs. This is baked into the spec.

There are three ways around it, and all of them have tradeoffs.

  1. Query parameters. Just shove the token in the URL: new EventSource('/events?token=abc123'). It works. It's also a bad idea for anything sensitive, because the token ends up in server access logs, browser history, the Referer header on subsequent requests, and potentially in CDN logs. Fine for short-lived session tokens with tight scopes, risky for long-lived API keys.
  2. Cookies. If you can set an HttpOnly cookie during a prior login step, the browser will automatically include it on the EventSource request. No custom code needed. The catch is that cookies come with their own baggage: same-site rules, CORS restrictions, and the fact that they don't fit cleanly with auth built around bearer tokens (which most modern APIs are).
  3. fetch() with ReadableStream. This is the most capable workaround. You use the Fetch API directly, which means you can set any headers you want, and then parse the SSE stream out of the response body yourself. The downside: you give up everything that makes EventSource pleasant to use. No automatic reconnection. No Last-Event-ID tracking. No built-in event parsing. You're writing an SSE parser in application code.
javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Workaround: fetch() with custom headers const controller = new AbortController(); const response = await fetch('/events', { headers: { 'Authorization': 'Bearer my-token' }, signal: controller.signal, }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split('\n\n'); buffer = parts.pop(); // keep incomplete chunk for (const part of parts) { const dataLine = part.split('\n').find(l => l.startsWith('data: ')); if (dataLine) { const data = JSON.parse(dataLine.slice(6)); handleEvent(data); } } }

In practice, nobody writes this from scratch. Libraries like @microsoft/fetch-event-source wrap the pattern and add back the reconnection and parsing logic you lost. This is what's happening under the hood in OpenAI's SDK, Anthropic's SDK, and the Vercel AI SDK. The somewhat good news is that the long-running open issue on the WHATWG HTML spec for this has seen some movement in the last few months. Fingers crossed, there will be a working implementation in a browser engine sometime in 2026.

WebSocket's header situation

WebSocket's upgrade handshake can carry any HTTP headers, which sounds promising until you realize the browser API doesn't expose that. The WebSocket constructor takes a URL and optional subprotocol strings, and that's it. There's no equivalent of the fetch() escape hatch SSE has, which makes WebSocket arguably the worst of the two when you need header-based auth.

Your options come down to the same three as SSE: query params (same risks as before), cookies (same caveats), or a two-step flow where the client hits an HTTP endpoint first to exchange credentials for a short-lived token, then connects to the WebSocket with that token in the URL. The two-step flow is the most common pattern in production, and it works fine, but it's worth recognizing that it exists because the API backed you into a corner, not because it's inherently the right design.

6. Protocol Overhead

Every message sent over either protocol has to carry some extra bytes along with the payload itself. Things like framing headers, field prefixes, and delimiters that tell the receiving side where one message ends and the next begins. That's protocol overhead, and WebSocket and SSE handle it very differently.

WebSocket has minimal binary framing

Once the upgrade handshake is done, WebSocket stops sending HTTP headers entirely. Every subsequent message just carries a tiny binary frame header, and that's it. The exact overhead depends on payload size:

  • Payloads up to 125 bytes get a 2-byte header: 1 byte for the opcode and flags, 1 byte for the length.
  • Payloads up to 65,535 bytes get a 4-byte header: the same 2 bytes plus 2 more for extended length.
  • Larger payloads get a 10-byte header: the same 2 bytes plus 8 more for the extended length.
  • Client-to-server frames add a 4-byte masking key on top of whatever the header is. This isn't optional; the spec requires it for security reasons.

Run the math on a small message. A 10-byte payload from client to server comes out to 20 bytes on the wire: 10 bytes of payload, 2 for the header, 4 for the masking key, and 4 more for length encoding. That's 60% overhead, which sounds bad until you scale up. A 100KB payload carries just 14 bytes of overhead, or 0.01%. At any message size that actually shows up in production, WebSocket framing overhead is minimal noise.

SSE uses text-based framing with HTTP compression

SSE is chattier, at least on paper. Every event gets a data: prefix (6 bytes per line), optional event: and id: fields, and a double-newline delimiter to mark the end.

Here's what a small event looks like, fully annotated:

id: 1\n           →  5 bytes
event: update\n   → 15 bytes
data: 0123456789\n → 16 bytes
\n                →  1 byte
                    --------
                    37 bytes total to send 10 bytes of payload

That's 270% overhead compared to WebSocket's 60%. But the ratio collapses fast as payloads get bigger, and SSE has one big thing going for it that WebSocket effectively doesn't: HTTP-level compression. Because SSE is just an HTTP response, the body runs through gzip or deflate like any other response, and for text-heavy event streams, that typically knocks 70-90% off the wire size.

So where does that leave you? For small, frequent messages (chat, typing indicators, presence updates), WebSocket wins on per-message overhead. For larger text payloads or high-throughput event streams with compression enabled, SSE often matches or beats WebSocket. And for almost every real application, none of this actually matters. Network round-trip time, JSON serialization, and whatever your backend is doing will dominate the latency budget by orders of magnitude before framing overhead shows up on the bill.

Payload SizeWebSocket OverheadSSE OverheadWS Wire TotalSSE Wire Total
10 bytes6B (60%)27B (270%)16B37B
1 KB6B (0.6%)\~30B (2.9%)1,030B\~1,054B
100 KB14B (0.01%)\~630B (0.6%)\~100,014B\~100,630B

Scaling and Infrastructure

The protocol differences show up in infrastructure in ways that can catch teams off guard at scale. What looks like a minor technical choice early on turns into a load balancer configuration headache, a CDN limitation, or a surprise on the mobile battery report six months later.

Where SSE pulls ahead

  • Statelessness. Any server instance can pick up any client on reconnect, thanks to Last-Event-ID. No sticky sessions, no session affinity, no consistent hashing. You scale with the same HTTP autoscaling metrics you use for everything else.
  • CDN edge distribution. SSE streams pass through Cloudflare, Fastly, and CloudFront without special configuration, which means you can fan out at the edge: one origin connection feeding thousands of edge clients. That's a structural win WebSocket can't replicate.
  • Lower memory footprint. An SSE connection costs roughly 2-5 KiB of server-side state. WebSocket is closer to 50 KiB or more, because the server has to track frame buffers, masking state, and ping/pong for every client. Over tens of thousands of concurrent users, that difference stops being a rounding error.
  • Mobile battery. WebSocket's ping/pong keepalives (every 25-30 seconds) keep the cellular radio awake between messages. SSE uses TCP keepalives and HTTP heartbeat comments, which play nicer with radio sleep cycles. Real-world measurements put WebSocket at 2-3x the battery drain of HTTP streaming on mobile.

The upshot: SSE scales the way the rest of your HTTP infrastructure already does. There's no separate playbook to learn, and most of your existing tooling just works.

Where WebSocket pulls ahead

  • Per-message latency. WebSocket's binary framing runs about 1-3ms per message, compared to 5-10ms for SSE. Usually irrelevant (network RTT and backend processing dominate the budget), but for financial trading or competitive gaming, the gap matters.
  • Client-to-server efficiency. When the client sends almost as often as it receives (like in chat), a single bidirectional pipe beats pairing an SSE stream with a separate POST per outbound message. You save on HTTP overhead, connection setup, and round-trip wait.

The catch: WebSocket connections have to terminate at the origin, because CDNs don't transparently proxy them. Horizontal scaling means picking between sticky sessions or a pub/sub layer like Redis. Both work. Both add operational complexity you don't have with SSE.

When to Choose Which

Reach for SSE when the data flows mostly in one direction, server to client. Common fits include:

  • Dashboards and monitoring views
  • Notification feeds
  • Live scoreboards and stock tickers
  • CI/CD build logs
  • LLM token streaming

If the client occasionally needs to send something back (submitting a form, toggling a setting, sending a prompt), a plain HTTP POST alongside the SSE connection handles it cleanly. You don't need to cram everything through one connection just because it could go there.

SSE also wins anywhere infrastructure simplicity matters:

  • Works through every proxy, CDN, and load balancer
  • Deploys naturally on serverless platforms
  • Reconnects automatically with message replay
  • Scales horizontally without sticky sessions

The less real-time infrastructure you have to babysit, the more time you have for the actual product.

Reach for WebSocket when you genuinely need bidirectional communication on a single channel. Chat is the canonical example, where every participant is sending and receiving continuously:

  • Messages flow in both directions
  • Typing indicators update in real time
  • Presence status changes constantly
  • Read receipts propagate to everyone

Collaborative editing has the same shape, with cursor positions, keystrokes, and document operations streaming from every connected client at once. Multiplayer games, financial trading interfaces, and anything else where both sides talk at high frequency all land in the same bucket.

WebSocket is also the right pick when:

  • You need native binary support (compact wire formats, raw media data)
  • You need the absolute lowest per-message latency

A note on chat and real-time feeds

Picking WebSocket is just the opening move. Everything that makes chat or a social feed actually work still has to be built on top of the raw transport:

  • Message ordering and delivery guarantees
  • Offline sync
  • Presence and typing indicators
  • Read receipts
  • Moderation and content filtering
  • Push notifications for backgrounded clients
  • Thread and channel management

That's a lot of engineering, and most of it isn't differentiated work. Stream's Chat SDK handles all of it out of the box, letting teams focus on the parts of their product that are truly unique to them.

Looking Ahead: WebTransport

This might be all superfluous. There's a third option on the horizon worth knowing about, even if it's not quite ready to pick yet.

WebTransport is built on HTTP/3 and QUIC, and it's designed to be what WebSocket would look like if you were inventing it today. It gives you multiplexed bidirectional streams (like WebSocket, but without head-of-line blocking when a packet drops), unreliable datagrams (useful for gaming and media, where a dropped frame is better than a stalled connection waiting for a retransmit), and it slots natively into the HTTP/3 stack.

The catch is browser support. As of 2026, WebTransport works in Chrome and Edge, is partially implemented in Firefox, and hasn't landed in Safari at all. Most of the interesting use cases (cloud gaming, live streaming, real-time collaboration at scale) are genuinely better served by WebTransport than WebSocket, but "better served" doesn't matter much if a third of your users can't connect. Give it another two or three years.

For now, the choice is between WebSocket and SSE. Start with SSE. Reach for WebSocket when bidirectional communication is a genuine requirement, not a speculative one. And if you find yourself building chat, collaboration, or any other real-time feature where the transport is just the first of many problems you need to solve, do yourself a favor and don't build it all from scratch.

Integrating Video with your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more