HTTP/2 and HTTP/3 for Event Streams Permalink to this section
Part of SSE Protocol Fundamentals & Architecture.
The single hardest limit in browser SSE has nothing to do with the protocol: over HTTP/1.1 a browser opens at most six connections per origin, and every open stream permanently holds one of them. Six tabs, or one tab with six feature-specific streams, and the origin is wedged — new page loads, images and API calls all queue behind connections that will never finish. Moving the same stream to HTTP/2 removes that ceiling entirely without changing a byte of the event stream format. This guide covers what actually changes when a stream runs over HTTP/2 or HTTP/3: how multiplexing and flow control interact with long-lived responses, which headers become illegal, what QUIC’s connection migration buys a mobile client, and how to verify which protocol version your streams are really using in production.
How It Works Permalink to this section
HTTP/2 replaces HTTP/1.1’s one-request-per-connection model with a single TCP connection carrying many independent streams, each identified by a stream ID and framed into HEADERS and DATA frames. An SSE response is simply a stream whose DATA frames keep arriving: the server sends HEADERS with :status: 200 and content-type: text/event-stream, then one or more DATA frames per event, never setting the END_STREAM flag. From the browser’s perspective the response body is still an endless sequence of bytes to feed to the parser; only the framing beneath it changed.
Three consequences follow directly from that framing.
Chunked transfer encoding disappears. HTTP/2 frames already carry their own length, so the Transfer-Encoding: chunked header is not merely unnecessary — it is illegal, along with Connection, Keep-Alive, Upgrade, and Proxy-Connection. Servers that hard-code Connection: keep-alive on their SSE responses emit a protocol error the moment the endpoint is served over HTTP/2. Most frameworks strip these automatically; the ones that do not produce a stream that works in development and fails behind a TLS-terminating proxy.
Headers are compressed once per connection. HPACK maintains a dynamic table shared across every stream on the connection, so the second and subsequent streams to the same origin pay only a few bytes for headers that would have cost several hundred each over HTTP/1.1. For an application opening several streams this is a real saving, though it is dwarfed by the connection-count benefit.
Flow control becomes explicit. Each stream has its own receive window, and so does the connection as a whole. A client that stops reading one stream stops that stream’s window from being replenished; the server’s writes to that stream then block while other streams on the same connection continue normally. This is strictly better than HTTP/1.1, where a stalled response blocks the entire connection — but it means backpressure now arrives per stream, which is what makes rate limiting and backpressure handling behave differently between protocol versions.
HTTP/3 keeps the same semantics and moves the transport from TCP to QUIC over UDP. Streams become QUIC streams with independent delivery guarantees, which eliminates head-of-line blocking at the transport layer: a lost packet belonging to one stream no longer stalls the others. For SSE the practical differences are connection migration — a QUIC connection survives a client changing network, identified by a connection ID rather than a four-tuple — and faster reconnection, since a resumed QUIC handshake can carry application data in the first flight.
# Which protocol is this stream actually using?
curl -sI --http2 https://api.example.com/events | head -1
# HTTP/2 200
curl -sI --http3 https://api.example.com/events | head -1
# HTTP/3 200
Server-Side Implementation Permalink to this section
Almost all real deployments terminate HTTP/2 at a proxy and speak HTTP/1.1 to the application. That is a supported and usually preferable arrangement: the browser gets multiplexing, and the application keeps the simple one-request-per-connection model it already handles. What matters is that the proxy is configured to stream rather than buffer, and that the application does not emit connection-specific headers that the proxy must strip.
# nginx: HTTP/2 (and HTTP/3) to the browser, HTTP/1.1 to the app.
server {
listen 443 ssl;
listen 443 quic reuseport; # HTTP/3
http2 on;
add_header Alt-Svc 'h3=":443"; ma=86400'; # advertise HTTP/3
location /events {
proxy_pass http://sse_backend;
proxy_http_version 1.1; # upstream stays HTTP/1.1
proxy_set_header Connection ""; # do NOT forward the client's Connection header
proxy_buffering off; # the single most important line
proxy_cache off;
proxy_read_timeout 3600s; # far above the heartbeat interval
chunked_transfer_encoding on; # applies to the upstream leg only
}
}
Serving HTTP/2 directly from the application is worth it only when you terminate TLS there too. Node’s http2 module requires a different server object and a different handler signature, and the stream API replaces the familiar ServerResponse:
// Node.js: native HTTP/2 SSE endpoint (TLS terminated here).
const http2 = require('node:http2');
const fs = require('node:fs');
const server = http2.createSecureServer({
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
});
server.on('stream', (stream, headers) => {
if (headers[':path'] !== '/events') {
stream.respond({ ':status': 404 });
return stream.end();
}
// No Connection / Transfer-Encoding headers — they are illegal over HTTP/2.
stream.respond({
':status': 200,
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
});
const heartbeat = setInterval(() => stream.write(': ping\n\n'), 15_000);
const send = (event) => {
// write() returns false when this stream's flow-control window is exhausted.
const accepted = stream.write(`id: ${event.id}\ndata: ${JSON.stringify(event.data)}\n\n`);
if (!accepted) applyBackpressurePolicy(event);
};
subscribe(send);
stream.on('close', () => { // fires on RST_STREAM or connection loss
clearInterval(heartbeat);
unsubscribe(send);
});
});
server.listen(443);
The Go standard library takes care of this transparently. net/http negotiates HTTP/2 whenever the server is started with TLS, and the same handler works for both versions — with one caveat worth knowing about, described under edge cases below.
// Go: the same handler serves HTTP/1.1 and HTTP/2; h2 is automatic under TLS.
func events(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-transform")
// Deliberately NOT setting Connection: keep-alive — illegal under HTTP/2
// and unnecessary under HTTP/1.1.
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
flusher.Flush() // send HEADERS before the first event
ctx := r.Context()
sub := hub.Subscribe()
defer hub.Unsubscribe(sub)
for {
select {
case <-ctx.Done(): // RST_STREAM, GOAWAY, or client gone
return
case ev := <-sub:
fmt.Fprintf(w, "id: %s\ndata: %s\n\n", ev.ID, ev.JSON)
flusher.Flush() // one DATA frame per event
}
}
}
func main() {
// ListenAndServeTLS enables HTTP/2 automatically.
log.Fatal(http.ListenAndServeTLS(":443", "cert.pem", "key.pem", nil))
}
Client-Side Consumption Permalink to this section
Nothing changes in client code. EventSource speaks whichever protocol the browser negotiated during the TLS handshake; there is no API to select or even to read the version directly. The same constructor, the same handlers, the same reconnection behaviour:
// Identical code — the protocol version is negotiated below the API.
const es = new EventSource('/api/events', { withCredentials: true });
es.addEventListener('price', (e) => render(JSON.parse(e.data)));
What changes is what you can now afford to do. Under HTTP/1.1, opening a stream per feature is a bug waiting for a sixth tab; under HTTP/2 the same design is merely slightly wasteful. The advice to multiplex named events over one connection still holds — it is cheaper on both ends and simplifies reconnection — but the cliff is gone.
To find out which protocol a page actually used, read it from the Performance API rather than guessing:
// Report the negotiated protocol for the stream request.
const entry = performance
.getEntriesByType('resource')
.find((r) => r.name.includes('/api/events'));
console.log(entry?.nextHopProtocol); // "h2", "h3", or "http/1.1"
nextHopProtocol is the ground truth from the browser’s own connection, which makes it the right value to send to analytics when you want to know what share of real users are getting multiplexed streams. Bear in mind that a corporate TLS-inspecting proxy commonly downgrades everything to HTTP/1.1, so the field data will not match your test environment.
Edge Cases & Network Interference Permalink to this section
- Illegal hop-by-hop headers.
Connection,Keep-Alive,Transfer-Encoding,Upgrade. A server that sets any of these over HTTP/2 causes a protocol error, usually surfacing as an immediate connection reset with no useful message in the browser console. Strip them at the application level rather than relying on the proxy. - Concurrent stream limits.
SETTINGS_MAX_CONCURRENT_STREAMSdefaults to 100 in most servers and 128 in nginx. This is far above the HTTP/1.1 cap of six but it is not infinite: an application that opens a stream per widget can still exhaust it, and the failure mode is new streams queueing invisibly rather than erroring. - Connection-level flow control stalls. Every stream shares the connection’s flow-control window (65 535 bytes by default). One stalled reader that never replenishes its window can consume connection window space and slow every other stream on the same connection. Raise
http2_body_preread_sizeand the initial window on high-fan-out deployments. - GOAWAY on graceful restart. When a server restarts it sends
GOAWAY, which terminates every stream on that connection at once — including all SSE streams. Clients then reconnect simultaneously. Send aretry:value with jitter before shutdown, as described in Event ID & Retry Mechanism Design. - Go’s HTTP/2 write deadline. Go’s HTTP/2 server applies
WriteTimeoutto the whole stream rather than to individual writes, so a non-zeroWriteTimeoutterminates every SSE response at that interval. Set it to zero on the server that serves streams and enforce timeouts per handler instead. - HTTP/3 and UDP blocking. Many corporate networks block UDP 443 outright. Browsers fall back to HTTP/2 automatically via
Alt-Svc, so this degrades rather than breaks — but it means HTTP/3 adoption in your metrics will be a fraction of what synthetic tests suggest. - Proxy downgrade in the middle. A CDN may speak HTTP/3 to the browser, HTTP/2 to its own edge, and HTTP/1.1 to your origin. Every leg needs buffering disabled independently, which is the same rule as in the proxy and CDN configuration guide.
Performance & Scale Considerations Permalink to this section
The dominant benefit is connection count, and it is a client-side benefit rather than a server-side one. A server holding fifty thousand HTTP/2 streams still holds fifty thousand logical streams — but if those streams are spread across ten thousand users with five tabs each, the number of TCP connections and therefore file descriptors can fall by a factor of five. That directly relaxes the descriptor pressure described in Connection Pooling for SSE Servers.
Per-event overhead also drops. An HTTP/2 DATA frame costs nine bytes of framing against the eight or so bytes of chunk framing under HTTP/1.1 — effectively a wash — but header compression means the request side of a reconnect is dramatically cheaper, which matters during a reconnect storm when thousands of clients re-open simultaneously.
The cost is memory per connection on the server or proxy. HTTP/2 connections maintain HPACK tables and per-stream state, typically a few kilobytes each on top of the socket. At high stream-per-connection ratios this is a bargain; at one stream per connection it is a small loss compared with HTTP/1.1.
TLS is mandatory in practice: no browser negotiates HTTP/2 without it. That makes the TLS record layer part of your latency budget, and it is one more place where small writes can be batched. Keeping one event per write, with an explicit flush, keeps one event per TLS record.
Validation & Debugging Permalink to this section
Start by confirming the negotiated protocol on the exact URL, not on the origin’s home page — routing rules frequently differ per path.
# Negotiated protocol, per URL.
curl -so /dev/null -w '%{http_version}\n' https://api.example.com/events
# Watch frames arrive over HTTP/2 without buffering.
curl --http2 -N -H 'Accept: text/event-stream' https://api.example.com/events
# Confirm no illegal hop-by-hop headers survive.
curl -sI --http2 https://api.example.com/events | grep -iE 'connection|transfer-encoding'
# (expect no output)
In Chrome DevTools, add the Protocol column to the Network panel: it shows h2, h3 or http/1.1 per request, and it is the fastest way to spot a stream that silently downgraded. For deeper inspection, chrome://net-export captures a NetLog that records HTTP2_SESSION events including SETTINGS, WINDOW_UPDATE and GOAWAY frames — the three things that explain most unexplained stream terminations.
Log the protocol version server-side as a first-class field so you can compare failure rates between versions:
// Express: record the protocol version with every stream open.
app.get('/events', (req, res) => {
logger.info({
event: 'sse_open',
http_version: req.httpVersion, // "1.1" or "2.0"
stream_id: req.stream?.id ?? null, // HTTP/2 only
user_id: req.user?.id,
});
// …
});
If reconnect rates differ sharply between 1.1 and 2.0 in that log, the cause is almost always an intermediary rather than your application — a proxy that terminates HTTP/2 with a shorter idle timeout than the HTTP/1.1 path, for example.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Does moving to HTTP/2 remove the need to multiplex named events over one stream?
It removes the hard failure, not the reason. Six streams per origin no longer wedges the connection, but each stream still costs a server-side registration, its own reconnection cycle and its own replay bookkeeping. One stream carrying several named event types remains simpler and cheaper on both ends; HTTP/2 just means a design with several streams degrades gracefully instead of breaking.
Why does my stream work locally but reset immediately in production?
The most common cause is a hop-by-hop header that is legal over HTTP/1.1 and illegal over HTTP/2. Locally you serve plaintext HTTP/1.1, so Connection: keep-alive is harmless; in production TLS termination negotiates HTTP/2 and the same header is a protocol error. Remove it and re-test with curl --http2.
Is HTTP/3 worth enabling specifically for Server-Sent Events?
For mobile clients, yes: QUIC connection migration means a stream survives a switch between wifi and cellular that would break a TCP connection outright, which removes a whole class of reconnects. For desktop clients on stable networks the gain is small. Enable it as a progressive enhancement — browsers fall back to HTTP/2 automatically when UDP is blocked.
How many concurrent SSE streams can one HTTP/2 connection carry?
Up to the server's advertised SETTINGS_MAX_CONCURRENT_STREAMS, commonly 100 to 128. Browsers respect that limit and queue additional streams rather than failing them, which makes exhaustion look like a hang rather than an error. Treat the limit as a real constraint if your application opens streams per component.
Does HTTP/2 change how backpressure reaches my server code?
Yes. Under HTTP/1.1 a slow reader fills the socket send buffer and your write blocks or returns false. Under HTTP/2 the stall is expressed as an exhausted per-stream flow-control window, so writes to that stream stop being accepted while other streams continue. The signal reaches your code the same way — a write that is not accepted — but it is now isolated per stream rather than per connection.