On this page
Replacing WebSockets with Server-Sent Events over HTTP/3
Evaluate when Server-Sent Events (SSE) over HTTP/3 is the better default for real-time web applications, and when WebSockets remain the correct choice.
Summary: For one-way data flows, server to client only, Server-Sent Events (SSE) over HTTP/3 is now a better default than WebSockets: less infrastructure to configure, automatic reconnection and resumption, and transport-level improvements it inherits for free. WebSockets remain the correct choice for bidirectional, high-frequency, or binary workloads.
For a decade, real-time on the web has been treated as synonymous with WebSockets. That default is worth re-examining. A large share of what teams build WebSockets for, including LLM token streaming, live notifications, dashboard updates, progress bars, and activity feeds, is fundamentally one-directional: server to client.
This article does not argue that SSE is better than WebSockets in general. It argues that for one-way streams, SSE removes an entire protocol’s worth of operational problems.
Also, HTTP/3 removes most of the historical performance reasons to avoid it. Where WebSockets are still correct, this article says so directly, in its own section.
Why the transport model matters more than the API
WebSockets are a separate protocol. The connection starts as HTTP, sends an Upgrade: websocket handshake, and then stops being HTTP: it becomes a raw, bidirectional, message-framed socket over TCP, or over QUIC if using WebTransport.
From that point on, every intermediary between client and server (load balancer, reverse proxy, CDN, corporate firewall, API gateway) has to recognize that this connection is special and must not be treated as a normal HTTP request.
SSE is not a separate protocol. It is an HTTP response. The server sets Content-Type: text/event-stream and keeps the response open, writing newline-delimited text frames as they become available.
Any piece of infrastructure that already handles a slow, streamed, chunked HTTP response, which is nearly all of it, already knows how to handle SSE. There is no upgrade handshake, no new protocol for a WAF to allow through, nothing to specially configure at the network layer.
That single property, that SSE is ordinary HTTP, is the source of almost every practical advantage described below.
What HTTP/3 changes for long-lived streams
HTTP/3 runs over QUIC, a transport built on UDP rather than TCP, standardized as RFC 9114 (HTTP/3) and RFC 9000 (QUIC). Three properties of QUIC are directly relevant to a long-lived, streaming HTTP response like SSE.
No cross-stream head-of-line blocking: in HTTP/2, all streams on a connection are multiplexed over a single TCP connection. If one TCP packet is lost, TCP’s in-order delivery guarantee stalls every stream on that connection until the lost packet is retransmitted. This stalls your SSE stream, even when the loss has nothing to do with it.
QUIC performs loss recovery per stream at the transport layer, so a loss on one stream does not stall the others sharing the connection. A page with an SSE connection open alongside ordinary asset requests won’t have its event stream stutter because an unrelated image request hit packet loss.
Connection migration: QUIC connections are identified by a connection ID rather than the traditional four-tuple of source IP, source port, destination IP, and destination port. A client that changes networks, such as a laptop moving from Wi-Fi to a mobile hotspot, or a phone switching from Wi-Fi to cellular, can keep the same QUIC connection alive across that change.
TCP-based connections, which includes HTTP/1.1, HTTP/2, and WebSockets over TCP, cannot do this. Any change to the client’s IP or port breaks the connection and forces a full reconnect. For long-lived streams consumed on mobile devices, this is the single most operationally significant HTTP/3 feature.
It is also relevant to WebSockets specifically, since it’s one of the few genuine advantages HTTP/3 extends to WebSockets, via extended CONNECT (RFC 9220). SSE gets the same benefit for free, as an ordinary property of any HTTP/3 response, with no extension required.
Faster connection establishment: QUIC combines the transport and TLS handshakes into a single round trip for new connections, and supports 0-RTT resumption for repeat connections to a known server. This mainly affects initial page load and reconnect latency, not steady-state throughput.
Note: HTTP/3 does not make SSE bidirectional, does not raise any per-message payload guarantee, and does not remove the need for your application to define reconnection and resume semantics, though SSE already provides these (see below).
None of the QUIC improvements above are SSE-specific either; HTTP/3 improves any long-lived HTTP response equally.
SSE benefits automatically because it is ordinary HTTP. WebSockets historically could not benefit at all, since they stop being HTTP after the handshake, until WebTransport and extended-CONNECT gave them a separate QUIC-native path.
Browser and server support
Client side: HTTP/3 is supported in Chrome 87+, Edge 87+, Firefox 88+, Safari 16+ (partial from 16.6, full in later 18.x builds on macOS and iOS), and Opera 74+. There is no current mainstream browser without HTTP/3 support.
The practical exception is enterprise networks that block outbound UDP/443 at the firewall. This forces a fallback to HTTP/2 (see the infrastructure section below).
Server side: support is mature at the origin and at the edge: nginx (built in from 1.25+, available earlier via patch), Caddy, Cloudflare, Fastly, AWS CloudFront and Application Load Balancer (via QUIC listener support), and Google Cloud Load Balancing all terminate HTTP/3 today.
If your origin server doesn’t speak HTTP/3 directly, terminating it at the CDN or edge and speaking HTTP/1.1 or HTTP/2 to the origin is the normal pattern. It works fine for SSE as long as nothing in the chain buffers the response (see the infrastructure section below).
EventSource API: EventSource has shipped since Chrome 6, Firefox 6, and Safari 5, and is available in roughly 97%+ of browsers in current use. Its behavior has not changed meaningfully in years.
| Feature | Chrome | Edge | Firefox | Safari | Opera |
|---|---|---|---|---|---|
EventSource API | 6+ | 79+ | 6+ | 5+ | 11+ |
| HTTP/3 (QUIC) | 87+ | 87+ | 88+ | 16+ (partial from 16.6) | 74+ |
What EventSource provides without extra code
EventSource handles reconnection automatically. If the connection drops, whether from a network blip, a server restart, or a proxy timing out an idle connection, the browser reconnects on its own.
The server can set the retry delay with a retry: field, and no application code is required for this behavior.
It also handles resumption, which is a different and more useful guarantee than reconnection alone. Each event can carry an id field. The browser remembers the last one it saw and, on reconnect, automatically sends it back as a Last-Event-ID request header.
The server reads that header and can replay only the events the client actually missed.
// Server (Node.js, plain http module, no framework needed)
import { createServer } from 'node:http';
const clients = new Set();
const eventLog = []; // in-memory ring buffer; use Redis/Kafka in production
function broadcast(data) {
const id = eventLog.length;
const event = { id, data };
eventLog.push(event);
const payload = `id: ${id}\ndata: ${JSON.stringify(data)}\n\n`;
for (const res of clients) res.write(payload);
}
createServer((req, res) => {
if (req.url !== '/events') return res.end();
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
// Not part of the spec, but respected by nginx-family proxies:
'X-Accel-Buffering': 'no',
});
// Resume: replay anything the client missed since it last saw an event
const lastEventId = Number(req.headers['last-event-id'] ?? -1);
for (const event of eventLog.slice(lastEventId + 1)) {
res.write(`id: ${event.id}\ndata: ${JSON.stringify(event.data)}\n\n`);
}
clients.add(res);
req.on('close', () => clients.delete(res));
}).listen(3000);
// Client
const source = new EventSource('/events');
source.onmessage = (event) => {
const payload = JSON.parse(event.data);
console.log('received', payload);
};
source.onerror = () => {
// The browser is already attempting reconnection automatically.
// This fires once per dropped connection, not once per failed retry.
console.warn('SSE connection interrupted, browser is retrying');
};
That is a complete, resumable, auto-reconnecting streaming endpoint in about thirty lines, using nothing but the standard library on both ends.
The equivalent WebSocket implementation requires you to build your own heartbeat mechanism to detect dead connections, your own reconnect-with-backoff logic on the client, and your own resume protocol. This is typically a sequence number sent back on reconnect, which functionally reinvents Last-Event-ID.
Example: LLM token streaming
This is the case most teams are actually solving today. The server disables response buffering explicitly. This single detail matters more than almost anything else in the SSE deployment story (see the infrastructure section below).
// Server: streaming an LLM completion token-by-token
app.get('/api/chat/stream', async (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
});
const stream = await llmClient.stream(req.query.prompt);
for await (const token of stream) {
res.write(`data: ${JSON.stringify({ token })}\n\n`);
}
res.write('event: done\ndata: {}\n\n');
res.end();
});
// Client
const source = new EventSource(`/api/chat/stream?prompt=${encodeURIComponent(prompt)}`);
let output = '';
source.onmessage = (e) => {
output += JSON.parse(e.data).token;
render(output);
};
source.addEventListener('done', () => source.close());
This mirrors what OpenAI’s and Anthropic’s own streaming APIs do under the hood. Both use text/event-stream for token streaming rather than WebSockets.
Known limitations
Be precise about these; they are real, and they determine whether SSE is the right fit for a given endpoint.
One-directional, structurally: SSE has no client-to-server channel. If the client needs to send data, such as a chat message, a cursor position, or a game input, that traffic goes over a separate, ordinary HTTP request (fetch/POST). This is correlated to the stream on the server side by session or connection ID.
For low-frequency client input, such as form submissions or chat messages sent every few seconds, this is a non-issue. It is arguably a cleaner separation of concerns than multiplexing both directions over one socket.
For high-frequency bidirectional traffic, such as collaborative text editing, multiplayer games, or voice and video signaling, a second HTTP request per message adds real overhead. WebSockets are the better fit here.
Text only: the text/event-stream format is UTF-8 text, line-delimited, with no binary framing.
Binary data, such as audio frames or protobuf messages, must be base64-encoded inside the data: field. This adds roughly 33% bandwidth overhead, or it isn’t sent over SSE at all. WebSockets support binary frames (ArrayBuffer/Blob) natively.
No custom headers on the native API: the EventSource constructor accepts only a URL and a withCredentials boolean, nothing else. An Authorization header cannot be set directly. In practice, this is worked around in one of three ways:
- Cookie-based authentication (
withCredentials: true), which works cleanly for same-site applications. - A short-lived token in the query string, accepting that it may be recorded in access logs. This is mitigated by using single-use, short-TTL tokens rather than long-lived credentials.
- Replacing
EventSourcewithfetch()and aReadableStreamreader, which allows full control over method, headers, and body at the cost of losing built-in reconnection. Libraries such as@microsoft/fetch-event-sourcerestore reconnection on top offetch.
// fetch-based consumption when an Authorization header is required
import { fetchEventSource } from '@microsoft/fetch-event-source';
await fetchEventSource('/api/chat/stream', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ prompt }),
onmessage(ev) {
render(JSON.parse(ev.data));
},
onerror(err) {
throw err; // by default this library retries; rethrow to stop
},
});
This is a genuine gap in the platform. The WHATWG has an open, long-stalled proposal to let EventSource accept custom headers natively, and it has not shipped. Until it does, non-cookie authentication requires either a polyfill or dropping to fetch.
Per-origin connection limits, mostly solved: under HTTP/1.1, browsers cap concurrent connections per origin at six. A seventh EventSource to the same origin queues behind the others. Under HTTP/2 and HTTP/3, requests to an origin multiplex over a single connection, so this stops being a practical limit for normal use.
A page with a handful of SSE streams and ordinary asset requests is unaffected. It matters only if you deliberately open many parallel EventSource connections to different origins with no HTTP/2 or HTTP/3 support, or if a client is somehow forced onto HTTP/1.1.
No built-in flow control: a slow consumer can cause the server to buffer outgoing data in the OS socket send buffer if the connection cannot drain fast enough. This backpressure problem exists for WebSockets too.
SSE has no application-level mechanism to signal that a client should slow down, unlike some binary protocols. In practice, you rely on transport-level backpressure, such as the server’s write() call blocking or returning false on Node streams, the same as any other streamed response.
Infrastructure behavior in production
This is the part most articles skip, and it determines whether a streaming endpoint works correctly in production or silently degrades into batches of data delivered every 30 seconds. Three failure modes are worth knowing about specifically.
Reverse proxy buffering: nginx buffers upstream responses by default (proxy_buffering on). For an SSE endpoint, nginx may collect streamed writes and flush them to the client in one burst rather than as they are generated.
The connection looks like a stream to your server code but arrives at the client as chunky batches, or not until the response ends. This is the single most common SSE-in-production bug report.
Fix it explicitly:
location /events {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
gzip off; # don't compress an event stream
proxy_read_timeout 3600s; # default timeouts will kill long-idle streams
chunked_transfer_encoding on;
}
The equivalent signal for backends behind nginx without per-location configuration is the X-Accel-Buffering: no response header, sent by the application itself. This is shown in the code samples above.
This is not standard HTTP, it is an nginx-specific convention. However, it is respected widely enough, including by several nginx-compatible ingress controllers, to be worth sending unconditionally. Other servers simply ignore an unrecognized header.
Similar defaults exist elsewhere: CDNs may buffer responses for compression or inspection unless the response is recognized as a stream. Most respect Content-Type: text/event-stream and disable buffering automatically. However, this should be verified against your specific provider’s documentation rather than assumed.
Idle timeouts: load balancers and proxies apply a default idle connection timeout, often 30 to 60 seconds, intended for ordinary request/response traffic. An SSE connection with sparse events, such as a notification stream that may be silent for minutes, can be killed by an intermediary that treats the silence as a dead connection.
Two mitigations are typically applied together: raise the proxy’s read/idle timeout for the streaming route specifically, and send periodic comment-only heartbeat lines from the server (: keepalive\n\n).
A leading colon makes the line a comment per the SSE spec, which EventSource ignores. But it is enough to keep the connection active from the transport’s point of view.
WebSocket infrastructure friction, for comparison: this is where the operational gap is largest. WebSockets require explicit Upgrade/Connection: Upgrade handling at every hop. Not all load balancers support this without configuration. Also, some managed platforms don’t support WebSocket upgrades at all, forcing a separate service just to host the socket.
They also typically require sticky sessions in horizontally scaled deployments. A WebSocket is pinned to one server process for its entire lifetime, and ordinary load balancing without connection affinity breaks it.
CDNs need separate handling too. They were built around cacheable request/response semantics and treat a persistent bidirectional socket as a special case to proxy through, not to cache or inspect the way ordinary HTTP is.
Capacity planning is also harder: A WebSocket server holds one long-lived connection per client indefinitely. This behaves differently under load and autoscaling than a fleet handling short HTTP requests.
None of this is unsolvable; WebSockets run at enormous scale in production. But each of these is infrastructure your team must explicitly own, test, and keep working across every proxy hop.
SSE is handled correctly by default by anything that already knows how to proxy streamed HTTP, provided buffering is turned off.
When WebSockets are still the right choice
Stated directly, without hedging:
- True bidirectional, low-latency, high-frequency traffic: multiplayer games, collaborative editing using operational transforms or CRDTs syncing continuously in both directions, and voice or video signaling. The cost of a second HTTP request per client message is not acceptable here.
- Binary protocols: streaming audio, video chunks, or a binary wire format without base64 overhead requires native binary frames. WebSockets provide this and SSE does not.
- Existing investment: a mature WebSocket deployment, meaning working connection management, horizontal scaling with sticky sessions or a pub/sub fan-out layer, and reconnection logic, is not worth rewriting on the basis that SSE is simpler alone. Migrate incrementally, if at all, starting with genuinely one-directional endpoints.
- Protocols that assume a socket: some existing application protocols, including certain multiplayer netcode, some financial market data feeds, and MQTT-over-WebSocket bridges, are specified in terms of a persistent bidirectional socket. Don’t force-fit SSE onto a protocol that isn’t shaped like a one-way stream.
The decision is not SSE versus WebSockets as a single architectural choice; it is per endpoint. A product can reasonably use SSE for notifications and dashboard updates while using WebSockets for its collaborative editor. The two are not mutually exclusive within one system.
Decision framework
Ask the following, per endpoint:
- Does the client need to send data on the same channel, frequently, with low latency? If yes, use WebSockets. If the client only occasionally sends data, such as form submissions or chat messages every few seconds, use SSE plus ordinary POST requests for the client-to-server direction.
- Is the payload binary? If yes, and encoding overhead is unacceptable, use WebSockets.
- Does your infrastructure support WebSocket upgrades without a workaround? If the honest answer is not without extra work, that is a real, ongoing cost. SSE avoids it entirely.
- Is header-based authentication required, and is a small client-side library acceptable? SSE still works via
fetch-based consumption. This is a minor addition, not a blocker.
For LLM token streaming, live notifications, dashboards, progress and status updates, and activity feeds, the answer to question 1 is almost always no. Together, these make up most of what gets labeled real-time in typical product development.
SSE over HTTP/3 is the more predictable operational choice for that category: fewer infrastructure special cases, reconnection and resumption built into the platform, and transport-level improvements it inherits for free by virtue of being ordinary HTTP.
Summary
| SSE over HTTP/3 | WebSockets | |
|---|---|---|
| Direction | Server to client only | Bidirectional |
| Transport | Ordinary HTTP response | Separate protocol after Upgrade |
| Reconnection | Automatic, browser-native | Must be implemented manually |
| Resumption (missed messages) | Built in (Last-Event-ID) | Must be implemented manually |
| Binary data | No (base64 workaround) | Native |
| Custom auth headers | Not via native EventSource; needs fetch | Native, via handshake headers |
| Load balancer / CDN handling | Standard, usually zero config beyond disabling buffering | Requires explicit upgrade support, often sticky sessions |
| Benefits from HTTP/3 transport improvements | Automatically | Only via separate WebTransport/extended-CONNECT mechanisms |
| Best fit | Notifications, token streaming, dashboards, progress updates | Chat/collaborative editing, multiplayer, binary streaming |
The claim here is narrow, and should stay narrow. For one-way streams, HTTP/3 removes most of the historical performance argument for WebSockets, and SSE removes almost all of the operational argument. That does not make WebSockets obsolete. It makes them a deliberate choice for bidirectional or binary workloads, rather than a default reached for out of habit.
Frequently Asked Questions
How do I bypass the 6-connection limit in Chrome for SSE?
If your infrastructure forces a fallback to HTTP/1.1, browsers will strictly limit you to 6 concurrent SSE connections per origin (ERR_INCOMPLETE_CHUNKED_ENCODING). To bypass this without upgrading to HTTP/2, you must domain-shard your SSE endpoints (e.g., stream1.api.com, stream2.api.com), or use a multiplexing wrapper library that routes multiple logical streams over a single physical EventSource.
Why does AWS Application Load Balancer drop my SSE connection exactly at 60 seconds?
ALBs have a hardcoded default idle_timeout of 60 seconds. Even if data is actively streaming, if a gap between server events exceeds 60 seconds, the ALB terminates the connection with a 504 Gateway Timeout. You must configure your Node/Go server to emit a comment frame (: ping\n\n) every 15-30 seconds to keep the idle timer resetting at the load balancer layer.
How do you gracefully handle JWT expiration on a long-lived SSE connection?
Because native EventSource cannot update headers mid-stream, handle token rotation by having the server emit an explicit event: auth_expired payload. The client listens for this, closes the EventSource, requests a new short-lived token or refreshes its cookie, and instantly reconnects. The built-in Last-Event-ID will smoothly recover any events dropped during the rotation gap.
What happens to the OS send buffer if an SSE client suspends their laptop?
If a client goes to sleep (or loses cell reception), the TCP window fills up. On the server, res.write() will begin to back up in the OS socket buffer. Once full, Node.js streams will buffer in memory. To prevent memory exhaustion during client disconnects, you must listen for the close event on the HTTP response object and actively clear the client from your broadcast Set.
Why does EventSource fail silently when proxying through Cloudflare?
Cloudflare disables response buffering automatically if it detects Content-Type: text/event-stream. However, if your origin server accidentally sends a Content-Length header along with it, Cloudflare assumes it is a static asset and waits for the connection to close before delivering data. Always use Transfer-Encoding: chunked and strip Content-Length for SSE.
Can you multiplex bidirectional RPCs over a one-way HTTP/3 SSE stream?
No. SSE is strictly simplex (server-to-client). If you attempt to emulate a bidirectional socket by making high-frequency HTTP POST requests alongside an SSE stream, you will incur massive TLS/QUIC overhead on every client message. For genuine bidirectional RPC architectures (like tRPC subscriptions), WebSockets over HTTP/1.1 or WebTransport over HTTP/3 are strictly superior.
How do you force Safari 16 to recover dropped Last-Event-IDs on cellular networks?
Safari versions prior to 17 occasionally fail to append the Last-Event-ID header when waking up from background state on cellular networks. To guarantee strict monotonic ordering, do not rely exclusively on the native header. Instead, extract the last seen ID manually and inject it into the reconnection URL as a query parameter (?cursor=123) when recreating the EventSource.
See also
- WHATWG: Server-sent events
- MDN: Using server-sent events
- MDN: EventSource
- RFC 9114: HTTP/3
- RFC 9000: QUIC
- RFC 9220: Bootstrapping WebSockets with HTTP/3
From the team at
We build digital products and explore the modern web standards behind them.