Prerender abort reason carries V8 stack frames, so a retained AbortSignal pins the whole render graph (1.7 MB vs 1 KB)
Nobody has claimed this yet.
- 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.js → io() →
abortOnSynchronousPlatformIOAccess → abortOnSynchronousDynamicDataAccess →
createPrerenderInterruptedError → controller.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 = 0around construction. ~12× cheaper than today (the capture itself
dominates), but it mutates a global andcontroller.abort(reason)dispatches listeners
synchronously, so widening that window silently strips stacks from unrelated errors.- Reading
.stackonce. Works only because Next installs aprepareStackTracereturning 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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