The HTTP/2 HEAD Request Bug That Hangs Clients

A content-length header on a HEAD response over HTTP/2 makes clients wait for a body that never arrives. The fix for Nginx, Node.js and Rust reverse proxies.

David Viejo
David Viejo · Updated · Published

How to Proxy HEAD Requests Correctly Over HTTP/2

A content-length header on a HEAD response is legal (RFC 9110 section 9.3.2 and RFC 9113 section 8.1.1 both allow it), so the real requirement over HTTP/2 is that the proxy ends the stream right after the HEADERS frame (END_STREAM set, no DATA frames). If a proxy or library forwards content-length: 45832 from the upstream but fails to end the stream, some clients wait for 45,832 bytes that never arrive. Stripping content-length on HTTP/2 HEAD responses is a pragmatic workaround for buggy proxies and libraries, not a protocol rule, and it must not be applied to HTTP/1.1 clients.

Most browser traffic now arrives over HTTP/2 or HTTP/3 (see Cloudflare Radar for current shares), so this isn't an edge case for any reverse proxy that speaks HTTP/2 to clients.

TL;DR: Make sure the HEADERS frame for an HTTP/2 HEAD response carries END_STREAM. If your proxy stack still stalls clients, detect method == HEAD on HTTP/2 connections and call remove_header("content-length") on the upstream response as a workaround. Do not strip it for HTTP/1.1 clients: on a keep-alive connection they need content-length to know the response is over. Temps hit this behind its Pingora-based proxy and applied the workaround.


Why Does content-length Break HEAD Responses Over HTTP/2?

HTTP/2's multiplexing model changes how clients interpret content-length. In HTTP/1.1, the client knows it sent HEAD and reads only the headers; content-length (or chunked encoding) is still what tells it, on a keep-alive connection, where the response ends. 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:

  1. Client sends HEAD on stream 5.
  2. Proxy forwards to upstream; upstream responds with content-length: 45832.
  3. Proxy sends HEADERS frame to client with content-length: 45832 included.
  4. Client sees the header and expects a DATA frame with 45,832 bytes.
  5. Proxy has no body to send — it was HEAD. It may or may not set END_STREAM on the HEADERS frame.
  6. 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 only a problem when the stream is not ended correctly after the headers.

Client Behavior Varies

How a stalled HEAD response fails depends on the client and its version: some report an error when the promised bytes never arrive, others wait until a timeout. We saw curl (nghttp2) error out through Temps' proxy; we haven't tested other clients here, so reproduce with curl -v --http2 -I https://your-host/ (and nghttp -v) against your own proxy version before applying the workaround.

--- | --- | | curl (nghttp2) | Errors when the promised body bytes never arrive (what we saw through Temps' proxy) | | 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 one client but break the monitoring tool your infrastructure team deployed last week.


Which Proxies Are Affected?

Custom proxies can get this wrong 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.

Behaviour differs between proxies and versions (Nginx, HAProxy, Envoy, Caddy, Traefik, Pingora, and custom hyper/h2 stacks), and we haven't verified each one, so test yours rather than trusting a table.

A custom proxy can ship with this bug until a monitoring tool starts reporting phantom timeouts in production.


The Fix

Strip content-length from the upstream response when the request method is HEAD and the downstream connection is HTTP/2 (or HTTP/3). 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.

Rust (Pingora / hyper)

This is the fix as it ships today in Temps's Pingora-based proxy (crates/temps-proxy/src/proxy.rs):

// Strip content-length from HEAD responses, but ONLY when the downstream
// client is on HTTP/2. Over HTTP/2, clients like curl interpret the
// content-length as a promise of body bytes and error when none arrive.
// An HTTP/1.1 downstream needs content-length (or chunked encoding) on a
// keep-alive connection to know the response is complete.
if ctx.method == "HEAD" && session.is_http2() {
    upstream_response.remove_header("content-length");
}

The first version of this fix didn't have the session.is_http2() check — see the follow-up section below.

For a standalone hyper proxy:

use hyper::{Request, Response, Method, Version, body::Bytes};
use http::header::CONTENT_LENGTH;

fn fix_head_response(
    req: &Request<()>,
    mut resp: Response<Bytes>,
) -> Response<Bytes> {
    // Only for HTTP/2 (and HTTP/3) downstream connections
    if req.method() == Method::HEAD && req.version() >= Version::HTTP_2 {
        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.

Node.js

import http2 from 'node:http2';

function proxyResponse(clientStream, upstreamHeaders, method) {
  const headers = { ...upstreamHeaders };

  // clientStream is an HTTP/2 stream, so strip content-length on HEAD
  // (an HTTP/1.1 response must keep it)
  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

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. This snippet doesn't check the protocol, so only use it where every client connects over HTTP/2; HTTP/1.1 clients need Content-Length on HEAD responses.


How This Bug Surfaces in Production

The symptoms are misleading. Here is how it typically plays out:

  1. 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.
  2. Week 2 — You add request logging to the proxy layer. HEAD requests arrive; upstream responds in 20ms. But the downstream connection hangs.
  3. 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.
  4. The fix — One condition in the response filter: check method and protocol, strip the header. The HTTP/2 timeouts stop.

The bug looks intermittent because it only affects HTTP/2 connections. And testing with plain curl can hide it: curl negotiates HTTP/2 over TLS when it's built with nghttp2, but uses HTTP/1.1 for plain-HTTP URLs, so pin the protocol with --http2 or --http1.1 when you test. 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: HEAD checks against apps served over HTTP/2 started timing out intermittently, and stripping content-length on HEAD fixed them.

The Follow-Up: Don't Strip It for HTTP/1.1

The first fix stripped content-length from every HEAD response. That fixed HTTP/2 and broke HTTP/1.1: curl -I through the proxy over a plain HTTP/1.1 keep-alive connection hung indefinitely. With neither content-length nor chunked encoding, an HTTP/1.1 client has no way to tell that a zero-length body is finished while the connection stays open, so it waits. The shipped code now checks session.is_http2() first. Test both protocols whenever you touch this code path.


How to Test for This Bug

Quick Check with curl

# 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.

Frame-Level Inspection with nghttp

# 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.
  • Any DATA frame after a HEAD — this is a protocol violation.

Monitor Your Access Logs

# 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.


How Temps Handles This

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 for HTTP/2 clients before any frame is written, leaving HTTP/1.1 responses intact. Every app deployed through Temps gets it automatically with no configuration — Temps is free to self-host (MIT / Apache 2.0), and Temps Cloud, a hosted control plane for your own servers, is coming soon. Where this filter sits among the proxy's other hooks is in request flow; if you rely on HEAD for health checks, see how to build an uptime monitoring system.


Frequently Asked Questions

Does this bug affect HTTP/1.1?

Not the stalled-body bug — HTTP/1.1 clients know they sent HEAD and don't read a body. But HTTP/1.1 has the opposite requirement: on a keep-alive connection the client needs content-length (or chunked encoding) to know the response is complete, so stripping it there can hang the client. Strip only for HTTP/2 and HTTP/3.

Should I strip content-length from all HEAD responses?

Only when the downstream connection is HTTP/2 or HTTP/3. For HTTP/1.1, keep content-length on HEAD responses — it tells the client the resource size and, on keep-alive connections, where the response ends.

How do CDNs handle this?

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.

Does HTTP/3 (QUIC) have the same issue?

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.

Can this cause data corruption?

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.


Key Takeaways

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 — and the protocol check is not optional.

Test it with curl -I --http2 and verify with nghttp frame inspection. Your monitoring tools, CDN prefetchers, and API clients will stop timing out.

#http2#reverse-proxy#head-request#debugging#nginx#rust#proxy HEAD requests HTTP/2