# 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 · Published 2026-03-12 · Updated 2026-10-20 · 7 min read

Canonical: https://temps.sh/blog/how-to-proxy-head-requests-correctly-http2

## 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](https://radar.cloudflare.com/) 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`](https://github.com/gotempsh/temps/blob/main/crates/temps-proxy/src/proxy.rs)):

```rust
// 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:

```rust
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

```javascript
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:

```nginx
# 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

```bash
# 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

```bash
# 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

```bash
# 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](https://temps.sh/docs/request-flow); if you rely on HEAD for health checks, see [how to build an uptime monitoring system](https://temps.sh/blog/how-to-build-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.

---

Source: https://temps.sh/blog/how-to-proxy-head-requests-correctly-http2 · Index of all pages: https://temps.sh/llms.txt
