Debugging HTTP/2 Stream Resets on SSE Permalink to this section
Part of HTTP/2 and HTTP/3 for Event Streams, under SSE Protocol Fundamentals & Architecture.
Over HTTP/1.1, a broken Server-Sent Events stream usually shows up as a closed connection. Over HTTP/2 it shows up as a stream reset — an RST_STREAM frame with an error code — and in Chrome’s console as net::ERR_HTTP2_PROTOCOL_ERROR 200 (OK). The status 200 in that message confuses everyone: the response started fine and something ended the stream abnormally later. This guide explains what resets mean, the four usual causes on SSE endpoints, and how to see the frames that tell you which one you have.
Symptom & Developer Intent Permalink to this section
- Chrome logs
net::ERR_HTTP2_PROTOCOL_ERROR 200for the stream request, sometimes immediately, sometimes after minutes. EventSourcereconnects repeatedly; the pattern disappears when the site is forced to HTTP/1.1.- The stream fails only behind a particular CDN or load balancer.
- Streams die at a consistent duration, such as 60 seconds or 5 minutes.
- Opening more than a certain number of tabs makes new streams hang.
The intent is to identify the component sending the reset and the reason, and fix it there.
Root Cause Analysis Permalink to this section
An HTTP/2 connection carries many streams. Either side can end one stream with RST_STREAM and an error code, without closing the connection. Browsers surface most abnormal resets as a protocol error on that request. The error code in the frame, and which hop sent it, identify the cause.
The four causes that account for most SSE resets:
- Connection-specific headers. HTTP/2 forbids
Connection,Keep-Alive,Transfer-EncodingandUpgradeheaders. Many SSE examples sendConnection: keep-alive. Some servers strip it when speaking HTTP/2; others pass it through, and the browser rejects the response with a protocol error. - Idle or maximum-duration timeouts in a proxy. A load balancer or CDN that gives up on a long response resets the stream toward the browser, often with
INTERNAL_ERROR. The consistent duration is the giveaway. - Concurrent stream limits. Each connection has a
SETTINGS_MAX_CONCURRENT_STREAMSlimit (commonly 100–250). Many long-lived streams on one connection can exhaust it; new requests queue or are refused. - Upstream failures translated to resets. When the HTTP/1.1 backend behind an HTTP/2 proxy closes abnormally — a crash, a killed worker — the proxy can only reset the client stream.
Step-by-Step Resolution Permalink to this section
Step 1 — Capture the frames Permalink to this section
# nghttp shows every frame, including RST_STREAM and its error code.
nghttp -v -H 'accept: text/event-stream' https://app.example.com/api/stream 2>&1 \
| grep -E 'recv (HEADERS|RST_STREAM|GOAWAY)|error_code'
# curl over HTTP/2 with verbose output also reports resets.
curl -v --http2 -N https://app.example.com/api/stream 2>&1 | grep -iE 'RST_STREAM|stream error|HTTP/2'
In Chrome, chrome://net-export records a log that the NetLog viewer displays per stream, including received RST_STREAM frames and their codes. That is the definitive record of what the browser saw.
Step 2 — Remove connection-specific headers Permalink to this section
// Send Connection: keep-alive only over HTTP/1.1.
const headers = { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' };
if (req.httpVersionMajor === 1) headers.Connection = 'keep-alive';
res.writeHead(200, headers);
Also check proxies that add headers: an nginx add_header Connection … or a middleware that sets Transfer-Encoding explicitly will break HTTP/2 even if the application is clean. Compare response headers over HTTP/1.1 and HTTP/2 with curl -sI --http1.1 and curl -sI --http2.
Step 3 — Beat every timeout with heartbeats, and raise maximums Permalink to this section
If resets happen after a fixed duration, find the component with that timeout and either raise it or keep the stream busy under its idle limit. Idle timeouts are beaten by heartbeat comments; maximum-duration limits (some CDNs cap response duration regardless of activity) must be raised or handled with a planned reconnect before the limit. The per-proxy settings are in proxy and CDN configuration for SSE.
Step 4 — Stay within concurrent stream limits Permalink to this section
Browsers share one HTTP/2 connection per origin, so all of a user’s tabs share its stream limit. A page that opens several streams per tab, multiplied by many tabs, can hit it. Consolidate to one stream per page with named events, and share one stream across tabs where possible, as in sharing one SSE connection across tabs. On the server, confirm the advertised limit:
http2_max_concurrent_streams 256; # nginx default is 128
Step 5 — Look behind the proxy Permalink to this section
If the reset code is INTERNAL_ERROR and the timing is irregular, check the backend’s logs at the same timestamps. Worker restarts, out-of-memory kills and unhandled exceptions in the stream handler all end the upstream connection abnormally, which the proxy reports to the browser as a reset.
Validation & Monitoring Permalink to this section
# After the fix: an idle stream held for 10 minutes over HTTP/2 with no reset.
timeout 600 nghttp -v https://app.example.com/api/stream 2>&1 | grep -c RST_STREAM # expect 0
Report client-side stream errors with the protocol in use (from performance.getEntriesByType('resource'), nextHopProtocol) so that HTTP/2-specific failures are visible separately from HTTP/1.1 ones.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why does Chrome report a protocol error with status 200?
The response headers arrived with status 200, and later the stream was reset. The error describes the abnormal end, not the start of the response.
Is Connection: keep-alive needed for SSE?
Only on HTTP/1.1, where it is the default anyway. On HTTP/2 it is forbidden, and sending it can make browsers reject the stream.
Does forcing HTTP/1.1 fix the problem?
It hides it and brings back the six-connections-per-origin limit. Fix the header or timeout instead and keep HTTP/2's multiplexing.
Can the server reset a stream intentionally?
Ending the response normally is better, because EventSource treats it as a clean end and reconnects. A reset produces an error on the client and noise in telemetry.