Version
v24.19.0 (production, Linux x64) and v24.14.0 (macOS arm64). Same with --no-async-context-frame.
Platform
Linux 6.x x64 (Hetzner), macOS 15 arm64
Subsystem
timers, async_hooks (AsyncLocalStorage)
What steps will reproduce the bug?
// node --expose-gc repro.js
'use strict'
const { AsyncLocalStorage } = require('node:async_hooks')
const als = new AsyncLocalStorage()
const N = 1000, PAYLOAD = 1 << 20
const keep = []
const mb = b => (b / 1048576).toFixed(0)
const measure = (l) => { global.gc(); global.gc(); const m = process.memoryUsage(); console.log(`${l.padEnd(36)} external=${mb(m.external)} MB`) }
measure('baseline')
let done = 0
for (let i = 0; i < N; i++) {
als.run({ id: i, payload: Buffer.alloc(PAYLOAD, 1) }, () => {
setImmediate(() => {
// 1 % of the callbacks create an Error and keep it (e.g. as an AbortSignal reason)
if (i % 100 === 0) keep.push(new Error(`callback ${i}`))
if (++done === N) setTimeout(finish, 50)
})
})
}
function finish() {
measure(`after ${N} immediates ran, ${keep.length} errors kept`) // ~1001 MB
for (const e of keep) void e.stack // format the stacks
measure('after reading .stack on the 10 errors') // ~2 MB
}
Output (v24.14.0):
baseline external=1 MB
after 1000 immediates ran, 10 errors kept external=1001 MB
after reading .stack on the 10 errors external=2 MB
Ten small Error objects keep all 1000 stores (1 GiB) alive after every callback has finished. Reading .stack on the ten errors releases everything.
How often does it reproduce?
Always.
What is the expected behavior?
Once an Immediate's callback has run, the Immediate should not keep its AsyncLocalStorage store (see #53408, closed as stale) nor its neighbours in the queue. An Error created in a callback should at most retain that one callback's environment.
What do you see instead?
The retention chain, read from a production heap snapshot (Node 24.19, 19 M nodes, retainer paths via reverse edges):
Error —<error_stack_symbol>→ FixedArray —[i]→ CallSiteInfo —[receiver]→ Immediate
Immediate —kResourceStore / kAsyncContextFrame→ store of THIS callback
Immediate —_idleNext→ Immediate —_idleNext→ … (every Immediate that ran in the same processImmediate() batch)
each of those —kResourceStore→ store of ANOTHER callback
Three things combine:
- V8 keeps an Error's structured stack frames (
CallSiteInfo, including each frame's receiver) until .stack is read. processImmediate() calls immediate._onImmediate() as a method, so the Immediate is the receiver of that frame.
- The Immediate carries the store of the context it was created in (
kResourceStore with async_hooks-based ALS, kAsyncContextFrame in v24's default mode), and it is not cleared after the callback ran. _onImmediate is nulled, the store is not.
processImmediate() walks the list with immediate = immediate._idleNext and resets only the list head/tail afterwards; executed Immediates keep their _idlePrev/_idleNext pointers. One retained Immediate therefore retains the whole batch.
Additional information
Real-world impact: React's Flight server (used by Next.js) aborts an AbortController with a new Error(...) from inside a setImmediate callback at the end of every render. On a self-hosted Next.js 16 / React 19.2 site with ~18 renders/s this retained 3 600+ finished HTTP requests after 8 minutes and OOM'd the process every 20–55 minutes (React side: react/react#37289; Next side: vercel/next.js#97434, #97351). Reading reason.stack in a wrapper around AbortController.prototype.abort stopped the growth immediately in production.
Even with the React fix, any library that keeps an Error created in an Immediate callback (logging, tracing, abort reasons) will pin the batch. Suggested fixes on the Node side: clear kResourceStore/kAsyncContextFrame on an Immediate after its callback ran (as proposed in #53408), and null _idlePrev/_idleNext when an Immediate is dequeued, so a retained Immediate is a leaf rather than a linked list.
Version
v24.19.0 (production, Linux x64) and v24.14.0 (macOS arm64). Same with
--no-async-context-frame.Platform
Linux 6.x x64 (Hetzner), macOS 15 arm64
Subsystem
timers, async_hooks (AsyncLocalStorage)
What steps will reproduce the bug?
Output (v24.14.0):
Ten small
Errorobjects keep all 1000 stores (1 GiB) alive after every callback has finished. Reading.stackon the ten errors releases everything.How often does it reproduce?
Always.
What is the expected behavior?
Once an Immediate's callback has run, the Immediate should not keep its AsyncLocalStorage store (see #53408, closed as stale) nor its neighbours in the queue. An
Errorcreated in a callback should at most retain that one callback's environment.What do you see instead?
The retention chain, read from a production heap snapshot (Node 24.19, 19 M nodes, retainer paths via reverse edges):
Three things combine:
CallSiteInfo, including each frame's receiver) until.stackis read.processImmediate()callsimmediate._onImmediate()as a method, so the Immediate is the receiver of that frame.kResourceStorewith async_hooks-based ALS,kAsyncContextFramein v24's default mode), and it is not cleared after the callback ran._onImmediateis nulled, the store is not.processImmediate()walks the list withimmediate = immediate._idleNextand resets only the list head/tail afterwards; executed Immediates keep their_idlePrev/_idleNextpointers. One retained Immediate therefore retains the whole batch.Additional information
Real-world impact: React's Flight server (used by Next.js) aborts an
AbortControllerwith anew Error(...)from inside asetImmediatecallback at the end of every render. On a self-hosted Next.js 16 / React 19.2 site with ~18 renders/s this retained 3 600+ finished HTTP requests after 8 minutes and OOM'd the process every 20–55 minutes (React side: react/react#37289; Next side: vercel/next.js#97434, #97351). Readingreason.stackin a wrapper aroundAbortController.prototype.abortstopped the growth immediately in production.Even with the React fix, any library that keeps an Error created in an Immediate callback (logging, tracing, abort reasons) will pin the batch. Suggested fixes on the Node side: clear
kResourceStore/kAsyncContextFrameon an Immediate after its callback ran (as proposed in #53408), and null_idlePrev/_idleNextwhen an Immediate is dequeued, so a retained Immediate is a leaf rather than a linked list.