Skip to content

An Error created inside a setImmediate callback retains the Immediate, its AsyncLocalStorage store, and — via stale _idleNext/_idlePrev links — the stores of every Immediate that ran in the same tick #66129

Description

@ManagrNo

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:

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions