Skip to content

fix(build): skip unhelpful precompressed variants - #2712

Merged
james-elicx merged 9 commits into
cloudflare:mainfrom
Boyeep:fix/precompress-beneficial-variants
Jul 27, 2026
Merged

fix(build): skip unhelpful precompressed variants#2712
james-elicx merged 9 commits into
cloudflare:mainfrom
Boyeep:fix/precompress-beneficial-variants

Conversation

@Boyeep

@Boyeep Boyeep commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

What changed

  • emit Brotli, gzip, and zstd variants independently only when their payload savings exceed the Content-Encoding and Vary response-field overhead
  • remove obsolete variants when content becomes incompressible or falls below the threshold
  • replace compressed files atomically
  • make the startup static cache reject non-beneficial encoded variants as defense in depth
  • prevent interrupted precompression temp artifacts from becoming servable URLs, while preserving matching public files

Why

Precompression previously wrote every encoding result regardless of size. High-entropy assets could therefore produce representations larger than the original, which the production server would still prefer during content negotiation. A merely smaller payload could also cost more on the wire once its required response fields were included. Reruns could retain variants that were no longer useful.

Impact

Precompressed representations reduce conservative HTTP/1 wire bytes, stale variants are cleaned up, partially written variants are not exposed to the production server, and legitimate public files remain unaffected.

Validation

  • vp test run tests/precompress.test.ts tests/static-file-cache.test.ts tests/serve-static.test.ts (99 tests)
  • vp check packages/vinext/src/build/precompress.ts packages/vinext/src/server/static-file-cache.ts packages/vinext/src/utils/precompressed-variant.ts tests/precompress.test.ts tests/static-file-cache.test.ts tests/serve-static.test.ts
  • vp run vinext#build
  • VINEXT_PRECOMPRESS=1 pnpm exec vinext build in tests/fixtures/pages-basic (15 assets, 45 encoded variants, 71.3% Brotli reduction)

@pkg-pr-new

pkg-pr-new Bot commented Jul 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2712
npm i https://pkg.pr.new/create-vinext-app@2712
npm i https://pkg.pr.new/@vinext/types@2712
npm i https://pkg.pr.new/vinext@2712

commit: bf287c9

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared bf287c9 against base 3d1ab6b using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.4 KB 132.4 KB ⚫ 0.0%
Client entry size (gzip) vinext 119.8 KB 119.8 KB ⚫ 0.0%
Dev server cold start vinext 2.82 s 2.82 s ⚫ -0.3%
Production build time vinext 3.07 s 3.05 s ⚫ -0.7%
RSC entry closure size (gzip) vinext 105.6 KB 105.6 KB ⚫ -0.0%
Server bundle size (gzip) vinext 179.8 KB 179.8 KB ⚫ -0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@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 precompression changes. The core logic is sound: variants are only emitted when they actually reduce wire bytes (accounting for negotiation-header overhead), stale variants are cleaned up on re-runs and on threshold/incompressibility transitions, and the atomic temp-then-rename write prevents partial variants from being exposed. The shared isPrecompressedVariantBeneficial helper keeps the build-time emit decision and the serve-time defense-in-depth check consistent, which is good.

A few things worth considering below. The only one I'd call more than nit-level is the orphaned .tmp file behavior on a mid-build crash.

return false;
}

const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;

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.

The temp-then-rename approach is correct for atomicity, but if the process crashes between writeFile and the catch/rename (e.g. SIGKILL, ENOSPC handled elsewhere, OOM), an orphaned foo.js.<pid>.<uuid>.tmp is left in _next/static/.

That matters because the serving side (static-file-cache.ts) registers every walked file that isn't a .br/.gz/.zst/.vite entry, so a stray .tmp becomes a servable static entry at its own pathname (with application/octet-stream content-type). The name is unguessable and the dir is immutable-cached, so severity is low, but it's cruft that can accumulate across failed builds.

Two mitigations to consider: (1) skip .tmp (or the whole *.<pid>.*.tmp shape) in walkFilesWithStats/StaticFileCache, and/or (2) sweep stale *.tmp files in the target dir at the start of precompressAssets. Option 1 is the cheaper safety net.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 3a6168a. The startup cache now excludes only vinext’s exact .br|.gz|.zst.<pid>.<randomUUID>.tmp shape, with a regression test proving an interrupted-write artifact is not registered as a servable URL.

if (outcomes.some(Boolean)) {
result.filesCompressed++;
result.totalOriginalBytes += content.length;
result.totalBrotliBytes += outcomes[0] ? brContent.length : content.length;

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.

Minor semantics change worth confirming is intentional: filesCompressed now increments when any variant is beneficial, and totalBrotliBytes falls back to content.length when brotli specifically isn't beneficial. In the edge case where brotli is never beneficial for any file but gzip/zstd are, totalBrotliBytes === totalOriginalBytes, so the summary line in index.ts:6546 prints 0.0% smaller with brotli while still reporting a nonzero filesCompressed. Not a bug, but the label may read oddly. Consider reporting the best-variant ratio, or renaming the metric, if you want the summary to stay meaningful in that case.

originalSize: number,
encoding: PrecompressedEncoding,
): boolean {
return originalSize - compressedSize > RESPONSE_HEADER_OVERHEAD[encoding];

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.

The overhead model counts both Content-Encoding and a full Vary: Accept-Encoding header as pure additional cost of the variant. In practice the original response also gains Vary: Accept-Encoding whenever any variant exists (see static-file-cache.ts:184-187), so Vary isn't strictly incremental to choosing the encoded representation. Since the comment already calls this "conservative," this is fine as a heuristic — just flagging that the threshold is slightly stricter than the true wire-byte delta, which means a small band of marginally-beneficial variants gets dropped. If that's the intended bias, no change needed.

Comment thread tests/precompress.test.ts

it("does not emit a marginally smaller representation with larger wire overhead", async () => {
const content = deterministicHighEntropyBytes(4096);
content.copy(content, content.length - 48, 0, 48);

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.

This test depends on brotli q5 output for this specific input landing in the narrow "smaller than original but within header overhead" window. It's guarded by the explicit assertions on lines 120-121, so it will fail loudly (not silently pass) if a zlib/brotli version bump shifts the output size out of that window — which is the right call. Just noting it may need a nudge to the input/mutation if brotli's behavior changes across Node versions in CI.

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review posted. Summary of what I found in PR #2712:

Overall assessment

The change is sound. Variants are emitted only when they reduce wire bytes (net of negotiation-header overhead), stale variants get cleaned up on re-runs and on threshold/incompressibility transitions, writes are atomic via temp-then-rename, and the shared isPrecompressedVariantBeneficial helper keeps the build-time emit decision and serve-time defense-in-depth consistent.

Feedback posted (4 inline comments)

  1. Orphaned .tmp files (the one non-nit)precompress.ts:199: a crash between writeFile and rename leaves foo.js.<pid>.<uuid>.tmp in _next/static/. The serving cache registers every non-variant file, so a stray .tmp becomes a servable entry. Low severity (unguessable name, immutable dir) but accumulating cruft. Suggested skipping .tmp in the static-file walk and/or sweeping stale tmp files at build start.

  2. filesCompressed / totalBrotliBytes semanticsprecompress.ts:173: in the edge case where brotli is never beneficial but gzip/zstd are, the summary prints 0.0% smaller with brotli with a nonzero file count. Not a bug, just a potentially misleading label.

  3. Overhead heuristic biasprecompressed-variant.ts:19: Vary isn't strictly incremental since the original also gets it, so the threshold is slightly stricter than the true delta. Fine as a documented conservative heuristic.

  4. Test fragilityprecompress.test.ts:116: the marginal-variant test relies on brotli q5 output landing in a narrow window, but it's guarded by explicit assertions so it fails loudly rather than silently.

No blocking issues.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

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

Re-reviewed after the tmp-file fix landed (3a6168a). The core design is sound and the earlier orphaned-.tmp concern is now addressed: variants are emitted only when they beat the negotiation-header overhead, stale variants are swept on re-runs / below-threshold / incompressible transitions, writes are atomic via temp-then-rename, and the shared isPrecompressedVariantBeneficial helper keeps build-time emit and serve-time defense-in-depth in lockstep. Tests are thorough and fail loudly rather than silently.

A few residual observations below — none blocking.

// A hard process exit can strand the temporary side of an atomic
// precompression write. Never register that internal artifact as a URL,
// but preserve matching user files outside the precompression target.
if (isHashed && PRECOMPRESSION_TEMP_FILE_RE.test(relativePath)) continue;

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.

The tmp-artifact guard is gated on isHashed. Precompression only ever writes tmp files under the hashed assets dir, so this is correct for the normal case. But the guard is doing double duty as a security/hygiene filter, and it silently does nothing for any tmp-shaped file outside the recognized hashed layout (e.g. an unusual assetPrefix layout that isHashed doesn't classify, or a user file under public/ that happens to match the shape). Since the whole point is "never register an internal artifact as a URL," consider testing the regex unconditionally and only using isHashed to decide cache-control, not whether to register at all. As written, the coupling of the safety filter to the hashed-path heuristic is a little surprising and worth a comment clarifying the intended scope.

if (outcomes.some(Boolean)) {
result.filesCompressed++;
result.totalOriginalBytes += content.length;
result.totalBrotliBytes += outcomes[0] ? brContent.length : content.length;

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.

totalBrotliBytes now falls back to content.length when brotli specifically isn't beneficial, while filesCompressed increments if any variant is beneficial. In the edge case where brotli is never beneficial but gzip/zstd are, totalBrotliBytes === totalOriginalBytes, so the summary at index.ts:6546 reports a nonzero file count with 0.0% smaller with brotli. Not a correctness bug, but the label reads oddly. Since the metric is now "the representation Brotli negotiation would serve" rather than raw brotli bytes, consider either renaming it (e.g. totalNegotiatedBytes) or reporting the best-variant ratio so the summary stays meaningful.

originalSize: number,
encoding: PrecompressedEncoding,
): boolean {
return originalSize - compressedSize > RESPONSE_HEADER_OVERHEAD[encoding];

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.

The overhead model charges the variant for a full Vary: Accept-Encoding line, but the original response also gains Vary whenever any variant exists (static-file-cache.ts:194-197), so Vary isn't strictly incremental to choosing the encoded representation — only Content-Encoding is. That makes the threshold slightly stricter than the true wire-byte delta and drops a thin band of marginally-beneficial variants. The doc comment already calls this "conservative," so this is fine as an intentional bias — just flagging that the Vary term is arguably double-counted against the variant.

const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
try {
await fsp.writeFile(temporary, compressed);
await fsp.rename(temporary, destination);

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.

fsp.rename over an existing destination is atomic on POSIX and overwrites on Windows (libuv passes MOVEFILE_REPLACE_EXISTING), so replacing an existing beneficial variant is safe cross-platform. One thing not covered: two concurrent precompressAssets runs against the same output dir (e.g. overlapping builds) each write a unique <pid>.<uuid>.tmp, so temps won't collide, but the final rename to the shared .br/.gz/.zst target still races last-writer-wins. That's almost certainly acceptable for a build step, but if concurrent builds into one dir is ever a supported scenario it's worth a note.

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of my re-review of PR #2712.

Overall assessment

The change is sound and the prior review's main concern (orphaned .tmp files) was fixed in 3a6168a. Variants are emitted only when their savings beat the Content-Encoding + Vary overhead, stale variants are swept on re-runs and on below-threshold/incompressible transitions, writes are atomic (temp-then-rename), and the shared isPrecompressedVariantBeneficial helper keeps build-time emit and serve-time defense-in-depth consistent. Tests are thorough and fail loudly.

Feedback posted (4 inline comments, none blocking)

  1. static-file-cache.ts:145 — tmp guard coupled to isHashed. The safety filter that prevents registering interrupted-write artifacts only runs when isHashed is true. Since the intent is "never register an internal artifact," it may be cleaner to test the regex unconditionally and use isHashed only for cache-control, or at least comment the intended scope.

  2. precompress.ts:173totalBrotliBytes label. Falls back to content.length when brotli isn't beneficial while filesCompressed counts any beneficial variant, so a gzip/zstd-only-beneficial edge case prints 0.0% smaller with brotli with a nonzero count. Suggested renaming the metric or reporting best-variant ratio.

  3. precompressed-variant.ts:19Vary double-counting. The original also gains Vary whenever any variant exists, so Vary isn't strictly incremental to the encoded representation; only Content-Encoding is. Fine as a documented conservative bias, just flagged.

  4. precompress.ts:202 — concurrent builds. rename is atomic/replacing cross-platform, but two overlapping builds into one dir race last-writer-wins on the final target. Almost certainly acceptable for a build step; noted in case that's ever supported.

No blocking issues.

github run

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

Independent re-review (/bigbonk). I read the full diff, ran vp test run tests/precompress.test.ts tests/static-file-cache.test.ts (52 passing), and verified the temp-file regex against real randomUUID()-based output. No blocking issues.

The design is sound:

  • Variants are emitted only when savings exceed Content-Encoding + Vary overhead (isPrecompressedVariantBeneficial), and the same helper gates serve-time selection, so build-time emit and serve-time defense-in-depth stay in lockstep.
  • Atomic temp-then-rename with per-write <pid>.<uuidv4>.tmp names, catch cleans up the temp on failure.
  • Stale variants are swept on re-runs and on below-threshold / incompressible transitions (removeCompressedVariants, removeFileIfPresent).
  • The interrupted-write artifact guard in static-file-cache.ts prevents a stranded .tmp from becoming a servable URL, and the test confirms a matching public/ file is still served.

The prior review rounds already covered the substantive points (.tmp guard scope, totalBrotliBytes label, Vary double-counting, concurrent builds). I agree with all of them and won't re-post duplicates. One residual observation below that I think is the most actionable of the non-blocking items.

return false;
}

const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;

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.

The serve-time guard in static-file-cache.ts now prevents a stranded .tmp from being served, which closes the security angle. But the artifacts still accumulate on disk across crashed builds: walkFiles only collects COMPRESSIBLE_EXTENSIONS, so .tmp files are never cleaned up by a subsequent precompressAssets run — only the serve-time filter hides them.

Consider a cheap sweep at the start of precompressAssets (or folding a .tmp match into the walk) so a failed build doesn't leave permanent cruft in _next/static/. Not blocking — the names are unguessable and the dir is immutable-cached — just noting the on-disk accumulation isn't self-healing.

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's my summary of PR #2712.

Overall assessment

The change is sound and well-tested. I independently verified the diff, ran the two targeted test files (52 tests passing), and confirmed the temp-file regex matches actual randomUUID()-based output. No blocking issues.

The design holds up:

  • The shared isPrecompressedVariantBeneficial helper gates both build-time emit and serve-time selection, so the two stay consistent.
  • Atomic temp-then-rename with per-write <pid>.<uuidv4>.tmp names; the catch cleans up the temp on failure.
  • Stale variants are swept on re-runs and on below-threshold/incompressible transitions.
  • The serve-time guard in static-file-cache.ts prevents a stranded .tmp from being served, while a matching public/ file is preserved (confirmed by the test).

Feedback posted

One inline comment (non-blocking): the serve-time guard closes the security angle, but orphaned .tmp files still accumulate on disk across crashed builds since precompressAssets never sweeps them — only the serve filter hides them. Suggested a cheap build-start sweep so the on-disk cleanup is self-healing.

The prior review rounds already covered the other substantive points (guard scope, totalBrotliBytes label, Vary double-counting, concurrent-build races). I agree with those and deliberately did not re-post duplicates.

github run

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

Independent /bigbonk re-review. I read the full diff, ran vp test run tests/precompress.test.ts tests/static-file-cache.test.ts (54 passing), and verified the temp-file regex against real randomUUID() output. No blocking issues — the change is correct and well-tested.

The design holds up:

  • isPrecompressedVariantBeneficial gates both build-time emit and serve-time selection, so the two decisions stay in lockstep and can't drift.
  • Atomic temp-then-rename with per-write <pid>.<uuidv4>.tmp names; the catch cleans up the temp on failure and rethrows.
  • Stale variants are swept on re-runs and on below-threshold / incompressible transitions (removeCompressedVariants, removeFileIfPresent).
  • The serve-time guard in static-file-cache.ts prevents a stranded .tmp from becoming a servable URL while preserving a matching public/ file (confirmed by the new test).

The prior review rounds already covered the substantive non-blocking items (.tmp on-disk accumulation, guard scope coupled to isHashed, totalBrotliBytes label, Vary double-counting, concurrent-build races). I agree with all of them. Below I re-surface only the single item I consider most actionable and still unaddressed in code — the others remain fine as documented/intentional.

return true;
}

async function removeCompressedVariants(fullPath: string): Promise<void> {

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.

On-disk .tmp accumulation is not self-healing (non-blocking).

The serve-time guard in static-file-cache.ts correctly hides a stranded temp file, so the security/correctness angle is closed. But nothing ever deletes an orphaned foo.js.br.<pid>.<uuid>.tmp from disk:

  • walkFiles (line 121) only collects COMPRESSIBLE_EXTENSIONS, so .tmp files are skipped by the build walk entirely.
  • removeCompressedVariants only removes .br/.gz/.zst (lines 211-213), never .tmp.

So after a crash between writeFile and rename, the temp lingers permanently across every subsequent build. Severity is low (unguessable name, immutable-cached dir, hidden at serve time), but the cleanup isn't self-healing. A cheap sweep at the start of precompressAssets — or folding the .tmp shape into removeCompressedVariants — would make failed builds leave no permanent cruft in _next/static/.

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review posted. Summary of my independent /bigbonk re-review of PR #2712:

Assessment

No blocking issues. The change is correct and well-tested. I read the full diff, ran the two targeted test files (54 passing), and verified the temp-file regex against real randomUUID() output.

The design is sound:

  • isPrecompressedVariantBeneficial gates both build-time emit and serve-time selection, keeping the two consistent by construction.
  • Atomic temp-then-rename with per-write <pid>.<uuidv4>.tmp names; failure path cleans up and rethrows.
  • Stale variants swept on re-runs and on below-threshold/incompressible transitions.
  • Serve-time guard prevents stranded .tmp from being served while preserving matching public/ files (confirmed by the new test).

Feedback posted

One inline comment (non-blocking): the on-disk .tmp accumulation is not self-healing. walkFiles only collects COMPRESSIBLE_EXTENSIONS and removeCompressedVariants only removes .br/.gz/.zst, so an orphaned temp from a crashed build lingers permanently — only hidden at serve time. Suggested a cheap build-start sweep.

The prior review rounds already covered the other residual items (guard scope coupled to isHashed, totalBrotliBytes label, Vary double-counting, concurrent builds). I agree with those and did not re-post duplicates — they're fine as documented/intentional decisions.

github run

@james-elicx
james-elicx merged commit 66ce817 into cloudflare:main Jul 27, 2026
56 checks passed
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.

2 participants