net::ERR_HTTP2_PROTOCOL_ERROR (tested)

net::ERR_HTTP2_PROTOCOL_ERROR means Chrome's HTTP/2 stream was reset or broken before the response finished. In our tests, almost any server-side stream reset showed up as this one error, whatever the reset code was. The fix is on the server or proxy side, so the job is to find which hop reset the stream.

1. What we measured

We ran a Node.js http2.createSecureServer with a self-signed certificate and HTTP/1.1 turned off. Each path broke the response a different way. We loaded each path in headless Chromium 153 and with curl --http2 -k.

Server behaviourChromium 153curl 8.14.1
Normal response (control)200, page loads200, exit 0
RST_STREAM before headers: stream.close(NGHTTP2_PROTOCOL_ERROR)net::ERR_HTTP2_PROTOCOL_ERRORcurl: (92) HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1)
Headers + 5003 bytes sent, then stream.destroy(err) (RST_STREAM with INTERNAL_ERROR)net::ERR_HTTP2_PROTOCOL_ERROR (page load hung, request failed)curl: (92) HTTP/2 stream 1 was not closed cleanly: INTERNAL_ERROR (err 2), 5003 bytes received
Headers + body written, then stream.close(NGHTTP2_INTERNAL_ERROR)No error, 5003 bytes receivedNo error, exit 0, 5003 bytes
content-length: 1000 but only 12 bytes sentNo error: page and fetch() got 12 bytescurl: (92) HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1)
Set connection: keep-alive on the responsenet::ERR_HTTP2_PROTOCOL_ERRORcurl: (92) HTTP/2 stream 1 was not closed cleanly: INTERNAL_ERROR (err 2)
Set transfer-encoding: chunked on the responsenet::ERR_HTTP2_PROTOCOL_ERRORcurl: (92) HTTP/2 stream 1 was not closed cleanly: INTERNAL_ERROR (err 2)

Three things stand out:

2. Connection headers: Node refuses, proxies may not

HTTP/2 forbids HTTP/1 connection-specific headers such as Connection, Keep-Alive and Transfer-Encoding. Node.js will not send them. In our run, stream.respond() threw:

ERR_HTTP2_INVALID_CONNECTION_HEADERS
HTTP/1 Connection specific headers are forbidden: "connection"
HTTP/1 Connection specific headers are forbidden: "transfer-encoding"

Our handler caught the throw and reset the stream, which Chromium reported as ERR_HTTP2_PROTOCOL_ERROR. So an app framework rarely puts these headers on the wire itself. When it happens in production, it is usually a proxy or CDN that copies an HTTP/1.1 backend header into its HTTP/2 response. Background: Transfer-Encoding chunked vs Content-Length.

3. Reproduce it with curl

curl names the reset code, which Chrome hides:

curl -sS --http2 -o /dev/null -w 'http=%{http_code} v=%{http_version} size=%{size_download}\n' https://example.com/page
echo "exit=$?"

Exit code 92 with was not closed cleanly means the stream was reset. PROTOCOL_ERROR (err 1) or INTERNAL_ERROR (err 2) is the code the server or proxy sent. If size is not 0, the reset came mid-body. Now force HTTP/1.1:

curl -sS --http1.1 -o /dev/null -w 'http=%{http_code} size=%{size_download}\n' https://example.com/page

If HTTP/1.1 works and HTTP/2 fails, the problem is in the HTTP/2 layer, usually a proxy. If both fail, the backend itself is cutting the response short. Compare ERR_CONNECTION_RESET and 502 Bad Gateway.

4. Common real causes (from docs and reports, not measured)

5. Fixes, in order

  1. Check the server and proxy error logs at the time of the failure. This is where the real cause is.
  2. Reproduce with curl --http2 to get the reset code, then with --http1.1 to compare.
  3. Disable HTTP/2 on the proxy for a short test (in nginx, drop http2 from the listen line). If the error goes away, the bug is in the HTTP/2 path.
  4. Free disk space, or fix permissions on nginx temp paths, or set proxy_max_temp_file_size.
  5. Remove Connection, Keep-Alive, Upgrade and Transfer-Encoding from responses a proxy forwards over HTTP/2.
  6. On the user side: clear the browser cache, try a private window, and pause HTTPS scanning in antivirus to rule it out.

Frequently asked questions

Does ERR_HTTP2_PROTOCOL_ERROR mean the server sent a PROTOCOL_ERROR code?

Not necessarily. In our test, Chromium 153 showed net::ERR_HTTP2_PROTOCOL_ERROR both when the server reset the stream with PROTOCOL_ERROR and when it reset it with INTERNAL_ERROR mid-body. Use curl --http2 to see the actual code.

What does curl error 92 mean?

It means the HTTP/2 stream was reset. We saw 'curl: (92) HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1)' and '... INTERNAL_ERROR (err 2)'. The part after the colon is the reset code.

Does a wrong Content-Length cause ERR_HTTP2_PROTOCOL_ERROR?

Not in our run. With content-length: 1000 and a 12-byte body, Chromium 153 loaded the page with no error, but curl 8.14.1 failed with error 92 PROTOCOL_ERROR.

Can I set Connection: keep-alive on an HTTP/2 response?

No. HTTP/2 forbids it. Node.js 24 throws ERR_HTTP2_INVALID_CONNECTION_HEADERS. In our test the resulting reset showed in Chromium as net::ERR_HTTP2_PROTOCOL_ERROR.

Is clearing the browser cache enough?

Only sometimes. It helps with a broken cached entry. Most cases come from the server or proxy, such as a disk full of nginx temp files or a backend that dies mid-response, so check the server logs first.

Written with AI assistance. Measured on 2026-10-02 with a Node.js 24.21.0 http2.createSecureServer (TLS, allowHTTP1 false), headless Chromium 153.0.8010.12 via Playwright, and curl 8.14.1 (nghttp2 1.64.0) with --http2 -k. Causes like nginx temp files, antivirus and Cloudflare come from docs and reports, not from our run. PlainHeaders is our extension; curl, Node.js and Chromium are free.