The HTTP/2 Bug That Makes Clients Hang Forever — HEAD Requests and Content-Length
A content-length header on a HEAD response over HTTP/2 causes clients to wait for a body that never arrives. The fix for Nginx, Node.js, and Rust reverse proxies.
Strip content-length from HEAD responses at your proxy layer. That is the entire fix. If your upstream returns content-length: 45832 on a HEAD response and your proxy forwards it unchanged over HTTP/2, some clients will wait for 45,832 bytes of body data that will never arrive. The connection stalls. Your uptime monitor times out. Your CDN prefetch fails silently.
Over 60% of web traffic now uses HTTP/2 or HTTP/3. This bug affects the majority of connections any reverse proxy handles in production today, and it is not an edge case — it has been filed against nearly every major reverse proxy project, including Pingora, Traefik, and HAProxy.
TL;DR: When proxying over HTTP/2, detect method == HEAD and call remove_header("content-length") on the upstream response before forwarding it to the client. Set END_STREAM on the HEADERS frame. Three lines of code; zero false timeouts.
HTTP/2's multiplexing model changes how clients interpret content-length. In HTTP/1.1, the client already knows it sent HEAD, so it ignores the content-length value — it reads headers and stops. In HTTP/2, each request lives in its own stream that ends when an END_STREAM flag appears on a HEADERS or DATA frame.
Here is the failure sequence:
Client sends HEAD on stream 5.
Proxy forwards to upstream; upstream responds with content-length: 45832.
Proxy sends HEADERS frame to client with content-length: 45832 included.
Client sees the header and expects a DATA frame with 45,832 bytes.
Proxy has no body to send — it was HEAD. It may or may not set END_STREAM on the HEADERS frame.
Client waits. Then times out.
RFC 9110 Section 9.3.2 says servers SHOULD include content-length on HEAD responses — it represents the size the body would have been. In HTTP/1.1, that is useful. In HTTP/2, it is a protocol-level trap.
Most custom proxies get this wrong by default because HTTP/2 libraries stay close to the wire format and leave policy decisions to the developer. The library gives you raw frames — it does not strip content-length for you.
Proxy
Default behavior for HEAD + HTTP/2
Safe by default?
Nginx
Strips content-length from HEAD over HTTP/2 frontend
Yes
HAProxy
Passes content-length through unchanged
No
Envoy
Configurable via http2_protocol_options
Depends
Caddy
Strips content-length from HEAD responses
Yes
Traefik
Passes through by default
No
Pingora (default)
Passes through unless explicitly handled
No
Custom (hyper, h2)
Almost always passes through
No
The "leave it to the developer" default means most custom proxies ship with this bug until a monitoring tool starts reporting phantom timeouts in production.
Strip content-length from the upstream response when the request method is HEAD. Keep everything else — content-type, etag, last-modified, cache-control. Only content-length causes the hang. Also ensure the HEADERS frame has END_STREAM set.
This is the exact fix shipped in Temps's Pingora-based proxy at crates/temps-proxy/src/proxy.rs:
// Strip content-length from HEAD responses. The upstream correctly includes it// (per RFC 9110 §9.3.2, HEAD responses SHOULD have the same content-length as GET)// but when proxied over HTTP/2, clients interpret the content-length as// a promise of body bytes and error when none arrive. Cloudflare strips it too.if ctx.method == "HEAD" { upstream_response.remove_header("content-length");}
For a standalone hyper proxy:
use hyper::{Request, Response, Method, body::Bytes};use http::header::CONTENT_LENGTH;fn fix_head_response( req: &Request<()>, mut resp: Response<Bytes>,) -> Response<Bytes> { if req.method() == Method::HEAD { resp.headers_mut().remove(CONTENT_LENGTH); // Replace body with empty to signal END_STREAM *resp.body_mut() = Bytes::new(); } resp}
Setting an empty body ensures the HTTP/2 codec sends END_STREAM on the HEADERS frame, which is exactly what the client expects.
The symptoms are misleading. Here is how it typically plays out:
Week 1 — Uptime monitor reports 2-3 timeouts per day for a specific app. App logs show no errors. The GET endpoint works fine in a browser.
Week 2 — You add request logging to the proxy layer. HEAD requests arrive; upstream responds in 20ms. But the downstream connection hangs.
Week 3 — You capture HTTP/2 frames with nghttp. The HEADERS frame includes content-length: 12847 but no END_STREAM flag. No DATA frame follows. The client waits.
The fix — One condition in the response filter: check method, strip header. Timeouts drop to zero.
The bug looks intermittent because it only affects HTTP/2 connections. HTTP/1.1 clients handle the same response correctly. Most developers test with curl, which uses HTTP/1.1 by default. You have to explicitly pass --http2 to trigger the issue. Browser DevTools do not show HEAD requests in normal browsing. The timeout looks like a network issue, not a proxy bug.
Temps hit this exact issue in its Pingora proxy layer. The uptime monitoring system — which sends HEAD requests every 30 seconds to check app health — started reporting intermittent timeouts for apps served over HTTP/2. The fix was the three-line snippet above. Timeouts dropped to zero immediately.
# Send HEAD over HTTP/2 and check for content-length in response headerscurl -I --http2 -v https://your-app.com/ 2>&1 | grep -i content-length
If you see content-length in the response, your proxy is passing it through. That may not guarantee a hang on every client, but it is a latent bug waiting to trigger.
Temps ships a Pingora-based reverse proxy as part of its single Rust binary. Pingora is the open-source proxy engine Cloudflare uses internally. The HEAD fix runs in response_filter and strips content-length before any HTTP/2 frame is written. Every app deployed through Temps — whether on Temps Cloud (~$6/mo, Hetzner + 30%) or self-hosted (free, Apache 2.0) — gets this fix automatically with no configuration.
No. HTTP/1.1 clients handle content-length on HEAD responses correctly. The client knows it sent HEAD, so it does not wait for body data regardless of what content-length says. The bug is specific to HTTP/2's multiplexed stream model.
Only when the downstream connection is HTTP/2 or HTTP/3. For HTTP/1.1, keeping content-length on HEAD responses is useful — it tells the client the resource size without downloading it. In practice, over 60% of traffic uses HTTP/2+, so you strip it more often than not.
Major CDNs like Cloudflare, Fastly, and AWS CloudFront strip content-length from HEAD responses at their HTTP/2 edge. If you are behind a CDN, you may be protected at the edge. But if your origin serves HTTP/2 directly — for API endpoints, internal services, or bypass routes — you still need the fix at the origin proxy layer.
No — it causes connection stalls and timeouts, not data corruption. No actual data is misdelivered. But the timeouts can cascade: a monitoring tool that hangs on HEAD may mark your service as down, triggering false alerts and automated failover actions.
HEAD requests are deceptively simple. The bug has existed since HTTP/2 shipped, and it only appears when your proxy is built on a low-level library that stays close to the wire and expects you to handle policy. The fix is three lines: check method, check protocol, strip the header.
Test it with curl -I --http2 and verify with nghttp frame inspection. Your monitoring tools, CDN prefetchers, and API clients will stop timing out.