vercel / vercel/next.js

Prerender abort reason carries V8 stack frames, so a retained AbortSignal pins the whole render graph (1.7 MB vs 1 KB)

Open
#97,351 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dynamic Routes Runtime
Dominant language
JavaScript
Stars
142k
Forks
32.4k
Avg merge
2d 14h
Merged PRs (30d)
351

Description

Link to the code that reproduces this issue

https://github.com/devopsaptlife/nextjs-prerender-abort-retention

To Reproduce
git clone https://github.com/devopsaptlife/nextjs-prerender-abort-retention
cd nextjs-prerender-abort-retention
npm install
npm run build
npm run instrument            # marks the two shipped creators, so the path is proven reached
npm start &                   # node --expose-gc .next/standalone/server.js
curl -s localhost:3000/p/a localhost:3000/p/b localhost:3000/p/c > /dev/null
cat hits.log                  # one HIT_DYNAMIC_RENDERING per request

Date.now() in a server component is synchronous platform IO, which under cacheComponents
routes through node-environment-extensions/io-utils.jsio()
abortOnSynchronousPlatformIOAccessabortOnSynchronousDynamicDataAccess
createPrerenderInterruptedErrorcontroller.abort(error).

Then, for what that error costs while it is reachable:

node --expose-gc repro.mjs    # standalone, no dependencies
payload per render: 8 MB x 20 renders = 160 MB

baseline     retained while signals alive:  160 MB (of 160 MB)  -> PINNED
materialize  retained while signals alive:    0 MB (of 160 MB)  -> released
limit0       retained while signals alive:    0 MB (of 160 MB)  -> released
Current vs. Expected behavior

Current: createPrerenderInterruptedError builds a plain new Error(message) which becomes
AbortSignal.reason. V8 keeps an Error's structured stack trace (its internal FixedArray of
CallSiteInfo) until .stack is read, and nothing reads it. Each retained frame pins that
frame's function → its Context → the render's whole working set: the cached page value
{ html, kind, postponed, rscData, segmentData, status }, its segmentData map of per-segment
RSC Buffers, plus the streams and promises. 1,756 KiB retained per reachable signal, versus
1 KiB if the reason carries no frames.

Expected: an abort reason should be cheap to keep. Retaining an AbortSignal — which
instrumentation, error reporting and userland code all do routinely — should not transitively
retain an entire rendered page.

Scope, stated honestly and up front: this is a latent amplifier, not a claim that Next leaks
on its own. I measured that it does not. With the path firing on every request (403 creator hits,
verified by the markers), a clean app accumulated nothing:

after rss heapUsed arrayBuffers live Errors live AbortSignals
0 pages 110 MB 27 MB 0 MB 502 3
80 295 MB 27 MB 0 MB 501 3
240 308 MB 26 MB 0 MB 501 3
320 299 MB 27 MB 0 MB 501 3

So Next collects these normally. The request is to make the reason cheap so that when something
holds a signal, the cost is ~1 KiB rather than ~1.7 MiB.

Why it matters in practice

On a production 16.3.0 deployment, from a full V8 heap snapshot with retainer paths computed over
strong edges only (weak edges retain nothing; including them yields plausible root paths that
keep nothing alive):

AbortSignal --property:<symbol kReason>--> Error --property:<symbol>--> array
  --internal:9--> system/CallSiteInfo --hidden[2]--> closure --internal:context-->
  system/Context --> Object{ html, kind, postponed, rscData, segmentData, status }
  --property:segmentData--> Map --> Buffer --> ArrayBuffer --> JSArrayBufferData
metric value
live AbortSignals 9,189 (3 in the clean app above)
live Errors still holding frames 5,214 of 6,448 — 48,640 frames
cached page values carrying segmentData 1,589
of those, reachable only via a CallSiteInfo frame 227 / 227 sampled
share of all ArrayBuffer bytes under segmentData 53%

Only ~270 of those 1,589 entries were inside Next's memoryCache LRU — which is correctly capped,
and whose sizing function does account for segmentData honestly. The rest were pinned outside
any cache, purely by frames. What keeps 9,189 signals reachable in that app is not identified
and may well be our problem, not Next's — but the frames are what turn it into ~2 GB instead of
~9 MB.

Provide environment information
Operating System:
  Platform: linux
  Arch: x64
Binaries:
  Node: 24.19.0
Relevant Packages:
  next: 16.3.0  (checked byte-identical in 16.3.1-canary.15)
  react: 19.2.7
  react-dom: 19.2.7
Next.js Config:
  output: standalone
  cacheComponents: true
  partialPrefetching: true
Which area(s) are affected? (Select all that apply)

Partial Prerendering (PPR), Runtime, Dynamic Routes

Additional context

Fix options, measured rather than reasoned about:

  • **error.stack = \${error.name}: ${error.message}`(assignment).** Thestack*setter* overwrites V8's private slot without invokingprepareStackTrace`. Hook-independent, no global
    state, ~0% CPU, message preserved.
  • Error.stackTraceLimit = 0 around construction. ~12× cheaper than today (the capture itself
    dominates), but it mutates a global and controller.abort(reason) dispatches listeners
    synchronously, so widening that window silently strips stacks from unrelated errors.
  • Reading .stack once. Works only because Next installs a prepareStackTrace returning a
    string. A hook returning the raw CallSite array, or one that throws, leaves the frames in place —
    measured, 152.6 MB still retained.

Two traps, both measured: delete err.stack and
Object.defineProperty(err, 'stack', {value}) do not release the frames — they drop the
accessor while the private slot survives. And retention is binary, not proportional:
stackTraceLimit of 10 / 5 / 3 / 1 all retain the full graph; only 0 releases it.

Bare aborts retain identically. Most of app-render.js's ~18 .abort(...) sites pass no
reason, so Node synthesizes a DOMException, which is instanceof Error and carries the same
lazy stack accessor — measured 1,756 KiB vs 1,754 KiB for abort(new Error()). Nine of those
sites are propagation of the form x.signal.addEventListener("abort", () => y.abort()), which
drops the incoming reason and mints a fresh DOMException.

Nothing in production reads these frames, so removing them looks observably neutral:
isPrerenderInterruptedError tests digest/name/message/instanceof;
create-error-handler.js early-returns on digest before its .stack reads; applyOwnerStack is
NODE_ENV !== 'production'-gated. But any replacement reason must keep
name === 'AbortError'pipe-readable.js's isAbortError tests exactly that, and several call
sites rely on it to avoid logging normal aborts as real errors.

Where the creator actually ships, checked against a real next build standalone output rather
than assumed, since this decides where a fix has to land:

file ships in standalone? contains the creator?
dist/server/app-render/dynamic-rendering.js yes yes
dist/compiled/next-server/app-route-turbo.runtime.prod.js yes yes
dist/compiled/next-server/app-page-turbo.runtime.prod.js yes no — tree-shaken to the checker
dist/compiled/next-server/app-page.runtime.prod.js no
dist/compiled/next-server/server.runtime.prod.js no

Related symptom report: #84648.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the linked reproduction and run its build, instrumentation, request, and node --expose-gc repro.mjs commands. Read createPrerenderInterruptedError in dist/server/app-render/dynamic-rendering.js and the shipped app-route-turbo runtime, then inspect abort handling and the named AbortError checks. Done means retained abort reasons no longer pin render data while preserving the required AbortError behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, next.js, node.js
Domain
backend, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.