Link to the code that reproduces this issue
https://github.com/arjenblokzijl/next-ppr-gzip-resume-repro
To Reproduce
-
npm install
-
npm run repro:unit — deterministic, server-free proof. It calls the installed
parsePostponedState exactly as base-server.js does on a resume
(body.toString("utf8")), passing a gzip-compressed postponed state. Output:
gzip header bytes: 1f 8b 08 00 (matches what we see in production)
Failed to parse postponed state Error: Invariant: invalid postponed state
-> parsePostponedState degraded to type:1 (logged error, no crash; HTTP 200)
-
npm run build — the single route reports as ◐ (Partial Prerender).
(The end-to-end path that calls the parser is driven by the platform's
internal resume routing — minimal mode + the prerender manifest — so a bare
next start doesn't exercise it; the unit repro isolates the failure
deterministically. See the repo README for the full code-path trace.)
Current vs. Expected behavior
On a PPR (Cache Components) resume, the postponed state is read from the POST
request body and decoded with body.toString("utf8") without honoring
Content-Encoding. When the resume body arrives gzip-compressed (as it
does behind Vercel's infrastructure), the gzip bytes decoded as UTF-8 are not a
valid postponed-state string, so parsePostponedState fails its ^<digits>:
check, logs Failed to parse postponed state + the Invariant, and degrades to
type:1 — a logged server error with an HTTP 200 fallback (the route cannot
resume its prerendered HTML). In production this fires steadily on every resumed
◐ route (cart/checkout/category pages, etc.).
Expected: the resume body is decompressed according to its Content-Encoding
(gzip/br/deflate) before toString("utf8")/parsePostponedState.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:06 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6030
Available memory (MB): 18432
Available CPU cores: 12
Binaries:
Node: 24.17.0
npm: 11.13.0
Yarn: N/A
pnpm: 9.5.0
Relevant Packages:
next: 16.3.0-preview.5 // Latest available version is detected (16.3.0-preview.5).
eslint-config-next: N/A
react: 19.2.4
react-dom: 19.2.4
typescript: 5.9.3
Next.js Config:
output: N/A
Which area(s) are affected? (Select all that apply)
cacheComponents
Which stage(s) are affected? (Select all that apply)
Vercel (Deployed)
Additional context
Root cause (code references)
server/base-server.js — resume-body read:
if (this.isAppPPREnabled && this.minimalMode &&
req.headers[NEXT_RESUME_HEADER] === '1' && req.method === 'POST') {
const body = await readBodyWithSizeLimit(req.body, maxPostponedStateSizeBytes);
const postponed = body.toString('utf8'); // ← no Content-Encoding decompression
addRequestMeta(req, 'postponed', postponed);
}
- server/lib/postponed-request-body.js — readBodyWithSizeLimit just concatenates
raw chunks; no decompression.
- server/app-render/postponed-state.js — parsePostponedState requires the state
to start with ^([0-9]*):, throws Invariant: invalid postponed state (E314),
and its own catch logs Failed to parse postponed state and returns a degraded
type:1.
The garbled state in the error starts with the gzip magic header 1f 8b 08.
- Not a regression — the read path (body.toString('utf8'), no decompression)
is byte-for-byte identical in 16.2.x and 16.3, so it reproduces on both. The
repro pins 16.3.0-preview.5 for convenience.
- Workaround: make affected routes fully dynamic (ƒ) so there's no postponed
state to resume (e.g. await connection() at the page root — 16.3 only). This
trades away the static shell, so it's only viable for session-specific routes,
not cacheable catalog/content pages.
Link to the code that reproduces this issue
https://github.com/arjenblokzijl/next-ppr-gzip-resume-repro
To Reproduce
npm installnpm run repro:unit— deterministic, server-free proof. It calls the installedparsePostponedStateexactly asbase-server.jsdoes on a resume(
body.toString("utf8")), passing a gzip-compressed postponed state. Output:gzip header bytes: 1f 8b 08 00 (matches what we see in production)
Failed to parse postponed state Error: Invariant: invalid postponed state
-> parsePostponedState degraded to type:1 (logged error, no crash; HTTP 200)
npm run build— the single route reports as◐ (Partial Prerender).(The end-to-end path that calls the parser is driven by the platform's
internal resume routing — minimal mode + the prerender manifest — so a bare
next startdoesn't exercise it; the unit repro isolates the failuredeterministically. See the repo README for the full code-path trace.)
Current vs. Expected behavior
On a PPR (Cache Components) resume, the postponed state is read from the POST
request body and decoded with
body.toString("utf8")without honoringContent-Encoding. When the resume body arrives gzip-compressed (as itdoes behind Vercel's infrastructure), the gzip bytes decoded as UTF-8 are not a
valid postponed-state string, so
parsePostponedStatefails its^<digits>:check, logs
Failed to parse postponed state+ the Invariant, and degrades totype:1— a logged server error with an HTTP 200 fallback (the route cannotresume its prerendered HTML). In production this fires steadily on every resumed
◐route (cart/checkout/category pages, etc.).Expected: the resume body is decompressed according to its
Content-Encoding(gzip/br/deflate) before
toString("utf8")/parsePostponedState.Provide environment information
Operating System: Platform: darwin Arch: arm64 Version: Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:06 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6030 Available memory (MB): 18432 Available CPU cores: 12 Binaries: Node: 24.17.0 npm: 11.13.0 Yarn: N/A pnpm: 9.5.0 Relevant Packages: next: 16.3.0-preview.5 // Latest available version is detected (16.3.0-preview.5). eslint-config-next: N/A react: 19.2.4 react-dom: 19.2.4 typescript: 5.9.3 Next.js Config: output: N/AWhich area(s) are affected? (Select all that apply)
cacheComponents
Which stage(s) are affected? (Select all that apply)
Vercel (Deployed)
Additional context
Root cause (code references)
server/base-server.js— resume-body read:raw chunks; no decompression.
to start with ^([0-9]*):, throws Invariant: invalid postponed state (E314),
and its own catch logs Failed to parse postponed state and returns a degraded
type:1.
The garbled state in the error starts with the gzip magic header 1f 8b 08.
is byte-for-byte identical in 16.2.x and 16.3, so it reproduces on both. The
repro pins 16.3.0-preview.5 for convenience.
state to resume (e.g. await connection() at the page root — 16.3 only). This
trades away the static shell, so it's only viable for session-specific routes,
not cacheable catalog/content pages.