TempsTemps
  • Docs
  • Blog
  • Pricing
  • Enterprise
  • Security
  • Contact
Star—
TempsTemps

Open-source deployment platform with built-in error tracking, analytics, and monitoring. Runs on any VPS. No surprise bills, no data leaving your infrastructure.

  • Product
  • Features
  • Documentation
  • Changelog
  • Enterprise
  • Contact
  • Resources
  • Getting Started
  • Upgrade
  • GitHub
  • Reddit
  • Tools
  • VPS Security Scanner
  • PaaS Tax Calculator
  • Compare
  • vs Vercel
  • vs Netlify
  • vs Coolify
  • All Platforms
  • Deploy
  • Next.js
  • Node.js
  • Django
  • Laravel
  • Go
  • Rust
  • All Frameworks →
  • Legal & Compliance
  • Security & Trust
  • Data Ownership & Privacy
  • GDPR Compliance

© 2026 Temps. All rights reserved.

GitHubDocs
t
Temps

The HTTP/2 Bug That Makes Clients Hang Forever — HEAD Requests and Content-Length

The HTTP/2 Bug That Makes Clients Hang Forever — HEAD Requests and Content-Length

March 12, 2026 (4mo ago)

Temps Team

Written by Temps Team

Last updated March 12, 2026 (4mo ago)

Free guide

Zero-Downtime Deployment Playbook

The deployment checklist used by teams shipping 10+ times per day without dropping a single request.

  • Blue-green vs rolling vs canary — when to use each
  • Health check configuration that actually catches failures
  • Preview environment setup for every PR
  • Automated rollback triggers and procedures

No spam. Unsubscribe anytime. Privacy policy

#http2#reverse-proxy#head-request#debugging#nginx#rust#proxy HEAD requests HTTP/2
Back to all posts

How to Proxy HEAD Requests Correctly Over HTTP/2

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.


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

  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 a protocol-level trap.

Free guide

Zero-Downtime Deployment Playbook

The deployment checklist used by teams shipping 10+ times per day without dropping a single request.

  • Blue-green vs rolling vs canary — when to use each
  • Health check configuration that actually catches failures
  • Preview environment setup for every PR
  • Automated rollback triggers and procedures

No spam. Unsubscribe anytime. Privacy policy

Client Behavior Varies

How badly this fails depends on the client:

ClientBehavior
curl (nghttp2)Handles correctly — ignores body for HEAD
Go net/httpMay hang depending on version and keep-alive settings
Python httpxRespects HEAD semantics — no hang
Node.js http2Can hang if END_STREAM flag is missing on HEADERS frame
Java HttpClientVaries 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.


Which Proxies Are Affected?

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.

ProxyDefault behavior for HEAD + HTTP/2Safe by default?
NginxStrips content-length from HEAD over HTTP/2 frontendYes
HAProxyPasses content-length through unchangedNo
EnvoyConfigurable via http2_protocol_optionsDepends
CaddyStrips content-length from HEAD responsesYes
TraefikPasses through by defaultNo
Pingora (default)Passes through unless explicitly handledNo
Custom (hyper, h2)Almost always passes throughNo

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.


The Fix

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.

Rust (Pingora / hyper)

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.

Node.js

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

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.

Free guide

Zero-Downtime Deployment Playbook

The deployment checklist used by teams shipping 10+ times per day without dropping a single request.

  • Blue-green vs rolling vs canary — when to use each
  • Health check configuration that actually catches failures
  • Preview environment setup for every PR
  • Automated rollback triggers and procedures

No spam. Unsubscribe anytime. Privacy policy


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


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


Frequently Asked Questions

Does this bug affect HTTP/1.1?

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.

Should I strip content-length from all HEAD responses?

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.

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.

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