ERR_HTTP2_PROTOCOL_ERROR means the browser gave up on an HTTP/2 stream because something about it violated the protocol. It is rarely a network problem. Almost always, something is producing a response that HTTP/2 will not accept - and HTTP/1.1 would have tolerated.
The first test: is it the response or the framing?
Fetch the failing URL both ways and compare:
curl -sSI --http2 https://yourdomain.com/the-failing-path
curl -sSI --http1.1 https://yourdomain.com/the-failing-pathA clean answer over HTTP/1.1 and an error over HTTP/2 tells you the content is fine and the framing or headers are not. That is the common case, and it narrows the search to three places.
Cause 1: an invalid response header
HTTP/2 is far stricter than HTTP/1.1 about headers. A header value containing a newline, a header name with an uppercase letter or an underscore where the stack does not expect one, or a duplicated header that must be unique will all be rejected outright where HTTP/1.1 would have shrugged.
Dump the headers and read them properly:
curl -sSv --http1.1 https://yourdomain.com/the-failing-path 2>&1 | grep '^<'Look for anything your application sets dynamically - a cache tag, a debug header, a header built from user input. Headers assembled from a database field or a filename are the usual source, because a stray newline in the data becomes a header injection the moment it is concatenated.
Cause 2: the backend closed the stream mid-response
If PHP crashes, exceeds its memory limit, or emits output after the response has started, the bytes on the wire stop agreeing with the frames that announced them. HTTP/2 notices immediately.
tail -f /var/log/nginx/error.log /var/log/php8.2-fpm.logReload the failing page with those open. Two log lines are worth looking for specifically: "Allowed memory size ... exhausted", which means the worker died partway through writing, and "headers already sent by", which means something printed output before the real response and everything after it is misaligned. Both are application bugs that HTTP/2 has simply made visible.
The most common source of the second one is a stray blank line after a closing PHP tag in an included file. Removing the closing ?> from files that contain only PHP prevents it entirely.
Cause 3: something in the middle rewriting frames
Proxies, load balancers, WAFs and HTTPS-inspecting antivirus all parse and re-emit HTTP/2, and not all of them do it correctly. This is the cause to suspect when the error is real for some visitors and unreproducible for you.
Test the origin directly, bypassing anything in front of it:
curl -sS -o /dev/null -w '%{http_code} %{http_version}\n' \
--resolve yourdomain.com:443:ORIGIN_IP https://yourdomain.comClean against the origin and broken through the public hostname puts the fault in the proxy layer. If you are behind a CDN, switching that hostname to HTTP/1.1 between the CDN and your origin is usually a supported setting and a reasonable fix.
Cause 4: a limit being crossed
If the error only appears on large responses, long URLs or requests with many cookies, you are hitting a size limit rather than a correctness problem. In nginx the relevant directives are:
http2_max_field_size 16k;
http2_max_header_size 32k;
large_client_header_buffers 4 16k;Long session cookies and JWTs in headers are the usual reason these get exceeded. Raising the limits works; reducing what you put in headers works better and is faster for everyone.
Turning HTTP/2 off, and why to treat it as temporary
Dropping http2 from the nginx listen directive will stop the error, because HTTP/1.1 accepts things HTTP/2 refuses. It also means every visitor loses multiplexing and header compression, which on an asset-heavy page is a real cost.
More to the point, the underlying response is still malformed. Anything else that speaks HTTP/2 - a CDN, an API client, a monitoring probe - will keep hitting it. Use the flag to buy time while you find the header or the log line, not as the resolution.