March 12, 2026 (3mo ago)
Written by Temps Team
Last updated March 12, 2026 (3mo ago)
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 == HEADand callremove_header("content-length")on the upstream response before forwarding it to the client. SetEND_STREAMon 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:
content-length: 45832.content-length: 45832 included.END_STREAM on the HEADERS frame.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.
How badly this fails depends on the client:
| Client | Behavior |
|---|---|
| curl (nghttp2) | Handles correctly — ignores body for HEAD |
Go net/http | May hang depending on version and keep-alive settings |
Python httpx | Respects HEAD semantics — no hang |
Node.js http2 | Can hang if END_STREAM flag is missing on HEADERS frame |
Java HttpClient | Varies by implementation — some wait for body |
The inconsistency is the problem. Your proxy might work with curl but break the monitoring tool your infrastructure team deployed last week.
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.
import http2 from 'node:http2';
function proxyResponse(clientStream, upstreamHeaders, method) {
const headers = { ...upstreamHeaders };
// Strip content-length from HEAD responses over HTTP/2
if (method === 'HEAD' && headers['content-length']) {
delete headers['content-length'];
}
clientStream.respond(headers, { endStream: method === 'HEAD' });
}
The endStream: true flag is the critical detail — it sends END_STREAM on the HEADERS frame.
Nginx handles this correctly for HTTP/2 frontends by default. For explicit control or custom builds:
# Force strip content-length for HEAD responses
if ($request_method = HEAD) {
more_set_headers -s "200 204 301 302" "Content-Length:";
}
Note: more_set_headers requires the headers-more-nginx-module. Setting the header to an empty value removes it.
The symptoms are misleading. Here is how it typically plays out:
nghttp. The HEADERS frame includes content-length: 12847 but no END_STREAM flag. No DATA frame follows. The client waits.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 headers
curl -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.
# macOS: brew install nghttp2
# Ubuntu: apt install nghttp2-client
nghttp -vn --no-dep https://your-app.com/ -H ':method: HEAD'
Look for:
HEADERS frame with END_STREAM flag — the proxy correctly signaled no body.content-length header in the HEADERS frame without END_STREAM — the client may hang.DATA frame after a HEAD — this is a protocol violation.# HEAD requests taking over a second are suspicious
grep "HEAD" /var/log/nginx/access.log | awk '$NF > 1.0 {print}'
HEAD responses should be faster than equivalent GET requests — there is no body to transmit.
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.
Yes. HTTP/3 inherits HTTP/2's multiplexing model over QUIC streams. If you strip content-length for HTTP/2, apply the same logic for HTTP/3.
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.