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 200 for the stream request, sometimes immediately, sometimes after minutes.
  • EventSource reconnects 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.

RST_STREAM error codes seen on SSE streams Matrix mapping common RST_STREAM error codes to their typical sender and cause on Server-Sent Events endpoints. RST_STREAM error codes seen on SSE streams Error code Usually sent by Typical SSE cause PROTOCOL_ERROR (1) browser or proxy forbidden connection header INTERNAL_ERROR (2) proxy or CDN upstream timeout or crash CANCEL (8) browser tab closed, es.close() REFUSED_STREAM (7) server stream limit reached FLOW_CONTROL_ERROR (3) either window bug in a proxy
The code narrows the cause; the sender narrows the component. Capture both before changing configuration.

The four causes that account for most SSE resets:

  1. Connection-specific headers. HTTP/2 forbids Connection, Keep-Alive, Transfer-Encoding and Upgrade headers. Many SSE examples send Connection: keep-alive. Some servers strip it when speaking HTTP/2; others pass it through, and the browser rejects the response with a protocol error.
  2. 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.
  3. Concurrent stream limits. Each connection has a SETTINGS_MAX_CONCURRENT_STREAMS limit (commonly 100–250). Many long-lived streams on one connection can exhaust it; new requests queue or are refused.
  4. 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.

A reset at a fixed duration identifies the timeout Timeline of five minutes showing streams through a CDN being reset at 100 seconds without heartbeats and surviving with 15-second heartbeats. A reset at a fixed duration identifies the timeout No heartbeat 15 s heartbeat idle stream reset, reconnect loop stream survives 0 60 120 180 240 300 seconds edge idle timeout
Resets clustered at one duration are a timeout, not a protocol bug. The heartbeat keeps the stream active for any idle timer.

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
Stream resets per 1,000 stream-hours before and after fixes Bar chart comparing HTTP/2 stream resets per thousand stream-hours with a Connection header and no heartbeats, after removing the header, and after adding heartbeats. Stream resets per 1,000 stream-hours before and after fixes Connection header + no heartbeat 1,800 Header removed 36 + 15 s heartbeats ~1 RST_STREAM received by browsers per 1,000 stream-hours
The header bug causes immediate, constant failures; the timeout bug causes a steady background rate. Both are needed for a clean stream.

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.