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 behaviour | Chromium 153 | curl 8.14.1 |
|---|---|---|
| Normal response (control) | 200, page loads | 200, exit 0 |
RST_STREAM before headers: stream.close(NGHTTP2_PROTOCOL_ERROR) | net::ERR_HTTP2_PROTOCOL_ERROR | curl: (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 received | No error, exit 0, 5003 bytes |
content-length: 1000 but only 12 bytes sent | No error: page and fetch() got 12 bytes | curl: (92) HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1) |
Set connection: keep-alive on the response | net::ERR_HTTP2_PROTOCOL_ERROR | curl: (92) HTTP/2 stream 1 was not closed cleanly: INTERNAL_ERROR (err 2) |
Set transfer-encoding: chunked on the response | net::ERR_HTTP2_PROTOCOL_ERROR | curl: (92) HTTP/2 stream 1 was not closed cleanly: INTERNAL_ERROR (err 2) |
Three things stand out:
- Chromium showed
ERR_HTTP2_PROTOCOL_ERRORfor a PROTOCOL_ERROR reset and for an INTERNAL_ERROR reset. The name does not tell you which reset code the server sent. curl does. - Calling
stream.close(NGHTTP2_INTERNAL_ERROR)after the body was written did not break anything in our run. Both clients got the full 5003 bytes. To break a stream mid-body we had to usestream.destroy(). - A too-large
Content-Lengthdid not trigger the error in Chromium 153. curl did reject it. See Content-Length mismatch.
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)
- nginx can't buffer the upstream response. With
proxy_buffering on, large responses go to temp files. A full disk or a temp directory nginx can't write to makes it abort the stream mid-body. Look foropen() ... failedorNo space left on devicein the nginx error log. - Backend crashes or times out mid-response. The proxy has already sent headers, so it can only reset the stream.
- Headers too large. Huge cookies or many
Set-Cookieheaders can go past HPACK or proxy header limits. - Compression mismatch. The response says
Content-Encoding: gzipbut the body is not gzip, or is gzip twice, or the length doesn't match after a proxy rewrite. - Antivirus or corporate HTTPS scanning. Tools that intercept TLS can mishandle HTTP/2. If only one machine sees the error, suspect this first.
- CDN or Cloudflare. The edge talks HTTP/2 to the browser and may reset the stream when the origin misbehaves. Test the origin directly to see which side is at fault.
5. Fixes, in order
- Check the server and proxy error logs at the time of the failure. This is where the real cause is.
- Reproduce with
curl --http2to get the reset code, then with--http1.1to compare. - Disable HTTP/2 on the proxy for a short test (in nginx, drop
http2from thelistenline). If the error goes away, the bug is in the HTTP/2 path. - Free disk space, or fix permissions on nginx temp paths, or set
proxy_max_temp_file_size. - Remove
Connection,Keep-Alive,UpgradeandTransfer-Encodingfrom responses a proxy forwards over HTTP/2. - 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.