Skip to content

fix(prod-server): send headers-only for HEAD in sendCompressed (#1980) - #2058

Merged
james-elicx merged 2 commits into
cloudflare:mainfrom
Divkix:fix/issue-1980-head-html-body
Jun 18, 2026
Merged

fix(prod-server): send headers-only for HEAD in sendCompressed (#1980)#2058
james-elicx merged 2 commits into
cloudflare:mainfrom
Divkix:fix/issue-1980-head-html-body

Conversation

@Divkix

@Divkix Divkix commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

sendCompressed (the Pages Router prod-server sender for buffered responses) wrote the full body for every request, including HEAD. HEAD requests that route through it — bot/crawler-buffered Pages HTML (pages-page-response.ts returns new Response(fullHtml, …) for isPagesStreamingBot) and Pages API routes — would compress and emit a payload. Every other sender already strips the body for HEAD: sendWebResponse (streamed path), tryServeStatic (static assets). sendCompressed was the lone exception.

This change short-circuits HEAD with res.end() (headers only) right after writeHead in both branches, mirroring sendWebResponse. The compressor is now created only when a body will actually be written.

Closes #1980.

Important nuance — this is an optimization + consistency fix, not a wire-behavior change

While implementing, I verified that Node's http.ServerResponse already suppresses HEAD response bodies at the socket level (_hasBody = false for HEAD): even an explicit res.end(buf) yields a 0-byte body while preserving Content-Length. So the observable HTTP response was already correct — for the HTML render path and API routes, before and after this change.

What the fix actually buys:

  • Avoids wasted work — without it, the compressed branch spins up a compressor and pumps the full body through pipeline() only for Node to discard it on HEAD.
  • ConsistencysendCompressed now matches sendWebResponse / tryServeStatic.

This also means the "API-route parity" angle from the issue is moot: Next.js runs on the same Node http server, so its API-route HEAD bodies are suppressed too — there is no observable divergence either way. The guard is therefore applied unconditionally inside sendCompressed (the issue's primary suggested fix), rather than scoped by content type.

Changes

  • packages/vinext/src/server/prod-server.tssendCompressed returns headers-only for req.method === "HEAD"; createCompressor moved below the guard.
  • tests/features.test.ts — 3 unit tests: HEAD on a compressible response (headers incl. Content-Encoding, empty body, no compressor); HEAD uncompressed (keeps Content-Length, empty body); non-HEAD GET still writes the full body (guard doesn't over-trigger).
  • tests/pages-router.test.ts — 2 integration tests: bot-buffered Pages SSR HEAD and Pages API HEAD both return status + headers with an empty body.

Test plan

  • sendCompressed unit tests: 9/9 pass
  • tests/pages-router.test.ts: 315/315 · tests/app-router-production-server.test.ts: 58/58
  • Typecheck + lint clean for all modified files

…flare#1980)

sendCompressed wrote the full body for every request. HEAD requests routed
through it (bot/crawler-buffered Pages HTML and Pages API routes) still
compressed and emitted a payload that Node then discards at the socket level.

Short-circuit with res.end() after writeHead when req.method is HEAD,
mirroring sendWebResponse. This skips spinning up a compressor for a body
that would be thrown away and keeps the buffered sender consistent with the
streamed and static-file senders, which already handle HEAD.
@pkg-pr-new

pkg-pr-new Bot commented Jun 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2058
npm i https://pkg.pr.new/vinext@2058

commit: b7f05b2

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the change. The fix is correct, minimal, and well-justified.

Verified:

  • The HEAD short-circuit in both branches of sendCompressed faithfully mirrors sendWebResponse (prod-server.ts:894-901): write headers (including Content-Encoding on the compressed branch), then res.end() with no body. tryServeStatic (prod-server.ts:696-729) uses the identical pattern, so all three buffered/streamed senders are now consistent.
  • Moving createCompressor(encoding) below the HEAD guard correctly avoids spinning up a compressor whose output Node would discard anyway.
  • In the compressed branch, Content-Length is (correctly) not emitted — matching sendWebResponse and tryServeStatic, where compressed responses omit it since the encoded size isn't known ahead of time. The PR description's RFC 9110 wording could be read as implying HEAD should always carry the full-body Content-Length, but for transfer-coded/compressed responses omitting it is the right call and matches the GET path. No change needed.
  • The integration tests exercise the real path: buffered bot SSR and Pages API responses both flow through sendCompressed (prod-server.ts:1846-1880) when a defaultContentType is present, so the /api/hello HEAD test genuinely covers the guard rather than only Node's socket-level suppression.
  • The PR description's nuance (Node already suppresses HEAD bodies via _hasBody = false) is accurate, so this is an optimization + consistency fix with no wire-behavior change.

Tested: vp test run tests/features.test.ts -t "sendCompressed" → 9/9 pass.

No blocking issues. LGTM.

@ask-bonk

ask-bonk Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2058.

Summary

The PR fixes sendCompressed to emit headers-only for HEAD requests, matching the behavior of the other two senders. My review verified:

  • Correctness & parity — The HEAD short-circuit in both branches mirrors sendWebResponse (prod-server.ts:894-901) and tryServeStatic (prod-server.ts:696-729) exactly. All three buffered/streamed senders are now consistent.
  • Optimization — Moving createCompressor() below the guard correctly avoids spinning up a compressor for a body Node would discard.
  • Content-Length — The compressed branch correctly omits Content-Length (unknown encoded size), matching the GET path and siblings. The only minor note I raised: the PR's RFC 9110 wording could be misread, but the actual behavior is right.
  • Test validity — Confirmed the integration tests genuinely route through sendCompressed (prod-server.ts:1846-1880), not just Node's socket-level body suppression. The /api/hello fixture returns a buffered JSON response, so the API HEAD test exercises the guard.
  • Tests passvp test run tests/features.test.ts -t "sendCompressed" → 9/9.

No blocking issues. The change is correct, minimal, and well-tested.

github run

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared b7f05b2 against base 3e5f676.

0 improved · 3 regressed · 3 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) Next.js 185.6 KB 185.6 KB ⚫ 0.0%
Client bundle size (gzip) vinext 128.8 KB 128.8 KB ⚫ +0.0%
Dev server cold start Next.js 2.39 s 2.53 s 🔴 +6.2%
Dev server cold start vinext 2.66 s 2.70 s ⚫ +1.4%
Production build time Next.js 4.25 s 4.42 s 🔴 +3.8%
Production build time vinext 2.91 s 2.96 s 🔴 +1.6%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5%

@james-elicx
james-elicx enabled auto-merge (squash) June 18, 2026 22:13
@james-elicx
james-elicx merged commit 7487886 into cloudflare:main Jun 18, 2026
46 checks passed
@Divkix
Divkix deleted the fix/issue-1980-head-html-body branch June 18, 2026 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Prod server: HEAD requests to bot-buffered HTML return a full body (sendCompressed)

2 participants