Repository navigation
Description
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.stackis read.processImmediate()callsimmediate._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 (
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 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.
Activity
xabierlameiro commentedon Sep 19, 2026
This doesn't reproduce on current 24.x or 26.x. It looks already fixed by #53443 (
timers: do not retain a reference to the async store after firing, which closed #53408): once an Immediate has run, it clears its async store,_onImmediateand_argv. It shipped in 24.20.0 and 26.6.0, and hasn't been backported to 22.x.Your repro unchanged,
node --expose-gc repro.js,externalafter the 1,000 immediates ran with 10 errors kept (officialnode:<version>-bookworm-slimimages, linux arm64):24.19.0 and 24.20.0 ran three times each, with and without
--no-async-context-frame, with the same numbers every time. On both lines the boundary is the release that carries #53443: v24.19.0 doesn't contain the commit and v24.20.0 does, and on 26.x it's listed in the 26.6.0 changelog.So on 24.x, upgrading to 24.20.0 or later removes the cross-request part. The error's own frames are still retained until
.stackis read, which is what react#37289 is about.fix(ssr): stop QueryProvider scheduling gc timers on the server