Skip to content

fix(cache): preserve binary fetch response bodies - #2907

Merged
james-elicx merged 1 commit into
mainfrom
codex/fix-binary-fetch-cache
Aug 13, 2026
Merged

james-elicx merged 1 commit into
mainfrom
codex/fix-binary-fetch-cache

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • store cached fetch() response bodies as base64 instead of lossy UTF-8 text
  • decode the stored bytes when replaying a cached response
  • bump the fetch-cache key version so deployments cannot replay entries written in the old text format
  • add a regression covering compressed, non-UTF-8 response bytes

Context

Response.text() replaces invalid UTF-8 sequences. The fetch cache previously serialized every response through text() while retaining the original headers, so binary or compressed bodies could be irreversibly changed on a cache hit.

This follows Next.js's CachedFetchData representation in patch-fetch.ts, which stores response bytes as base64.

The bug was reproduced on Cloudflare Workers with a tagged fetch whose response contained compressed bytes. The first fetch returned the original bytes, while the cached replay contained UTF-8 replacement bytes.

Validation

  • baseline regression failed with byte corruption on cache replay
  • vp test run tests/fetch-cache.test.ts (146 passed)
  • vp test run tests/isr-cache.test.ts tests/kv-cache-handler.test.ts (136 passed)
  • vp test run tests/shims.test.ts (1,291 passed)
  • vp check packages/vinext/src/shims/fetch-cache.ts tests/fetch-cache.test.ts
  • vp run vinext#build
  • git diff --check

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Aug 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: d6eb18e

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared d6eb18e against base 80557f7 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 135.4 KB 135.4 KB ⚫ +0.0%
Client entry size (gzip) vinext 122.8 KB 122.8 KB ⚫ +0.0%
Dev server cold start vinext 3.01 s 2.97 s ⚫ -1.2%
Production build time vinext 3.19 s 3.24 s ⚫ +1.4%
RSC entry closure size (gzip) vinext 114.4 KB 114.4 KB ⚫ +0.0%
Server bundle size (gzip) vinext 193.8 KB 193.8 KB ⚫ +0.0%

View detailed results and traces

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

@ask-bonk

ask-bonk Bot commented Aug 13, 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 Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

/bigbonk review

Verdict: LGTM. This is a faithful, correct port of Next.js's CachedFetchData base64 representation. No blocking issues found.

What I verified

1. Matches Next.js exactly. I checked the upstream patch-fetch.ts referenced in the PR body:

  • Write: Next.js does body: Buffer.from(bodyBuffer).toString('base64') in both createCachedPrerenderResponse and createCachedDynamicResponse. This PR does Buffer.from(await responseForCache.arrayBuffer()).toString("base64") in buildFetchCacheValue. Match.
  • Read: Next.js does new Response(Buffer.from(cachedFetchData.body, 'base64'), {...}). This PR does new Response(Buffer.from(data.body, "base64"), {...}) in buildCachedFetchResponse. Match.

2. Version bump is correct and effective. CACHE_KEY_PREFIX is baked into the SHA-256 cache key (fetch-cache.ts:467), so v4 (text-format) entries can never be read by the new base64 decoder — no silent corruption on deployments straddling the change. Good call bumping it.

3. No cross-contamination with unstable_cache / "use cache". Those paths also produce CachedFetchValue { kind: "FETCH" } (cache.ts:565, cache-runtime.ts:770), but they:

  • use distinct key namespaces (unstable_cache:...), so they never collide with fetch keys, and
  • read data.body through their own deserializers (tryDeserializeUnstableCacheResult, JSON.parse, base64ToUint8) — never through buildCachedFetchResponse.

So the base64 change is correctly isolated to the fetch() shim path. buildCachedFetchResponse is the only reader of a fetch-produced data.body, and buildFetchCacheValue is the only writer.

4. Runtime availability confirmed. cache-runtime.ts:280-287 already uses the identical Buffer.from(...).toString("base64") / Buffer.from(base64, "base64") pattern, so node:buffer is a proven-available dependency in all target runtimes (including Workers). The explicit import { Buffer } from "node:buffer" is consistent with repo style.

5. Edge cases fine.

  • Empty body → arrayBuffer() is "" → base64 "" → Buffer.from("", "base64") → empty Response. Round-trips cleanly.
  • data.body is typed as a required string, always populated by the writer — no undefined/null decode path.
  • Existing JSON tests (.json() round-trips at lines 98–120, etc.) still pass, so text bodies continue to work; the new test adds binary/compressed coverage. Both directions covered.

Test results

  • vitest run tests/fetch-cache.test.ts → 146 passed locally.

Minor (non-blocking) suggestions

  • Consider storing status on the write side using the same source as Next (it uses res.status; here responseForCache.status is used while url reads from the un-cloned response). Not a bug — clone preserves status/url — just a tiny inconsistency in which reference is read. No change required.
  • The content-encoding: gzip header is now correctly preserved alongside the raw compressed bytes, which is exactly what Next.js does. Worth noting for downstream consumers that call .text()/.json() on a cached gzip response: behavior is now identical to an uncached fetch (the runtime handles decoding), which is the whole point of this fix.

Nice, tight fix.

@ask-bonk

ask-bonk Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2907.

Summary

Verdict: LGTM — no blocking issues.

PR #2907 correctly fixes binary/compressed fetch response bodies being corrupted on cache replay (previously serialized through Response.text(), which lossy-replaces invalid UTF-8). I verified:

  1. Faithful Next.js port — the base64 write (buildFetchCacheValue) and read (buildCachedFetchResponse) exactly match upstream patch-fetch.ts (Buffer.from(...).toString("base64") / Buffer.from(body, "base64")).

  2. Version bump is effective — CACHE_KEY_PREFIX (v4→v5) is hashed into the SHA-256 cache key, so old text-format entries can never be read by the new decoder. No cross-deploy corruption.

  3. No cross-contamination — unstable_cache/"use cache" also produce kind: "FETCH" values but use distinct key namespaces and their own deserializers; the base64 change is correctly isolated to the fetch() shim path. buildCachedFetchResponse is the only reader and buildFetchCacheValue the only writer of a fetch-produced body.

  4. Runtime safety — node:buffer is already used identically in cache-runtime.ts, proving availability in all runtimes including Workers.

  5. Tests pass — ran vitest run tests/fetch-cache.test.ts locally: 146 passed. Existing JSON round-trip tests confirm text bodies still work; the new test adds binary coverage.

I flagged two minor, non-blocking notes (a cosmetic inconsistency in which Response reference status/url read from, and a clarification about gzip decoding parity). Neither requires changes.

github run

@james-elicx
james-elicx marked this pull request as ready for review August 13, 2026 08:15
@james-elicx
james-elicx merged commit c7d5e7b into main Aug 13, 2026
66 checks passed
@james-elicx
james-elicx deleted the codex/fix-binary-fetch-cache branch August 13, 2026 08:15
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.

1 participant