Skip to content

Memory leak with Node.js 24 due to AsyncContextFrame + enterWith() #3870

@atma

Description

@atma

Memory leak with Node.js 24 due to AsyncContextFrame + enterWith()

Description

Node.js 24 changed the default AsyncLocalStorage implementation to use AsyncContextFrame (via nodejs/node#55552). Under this new default, the enterWith() method — which the New Relic agent relies on — causes memory leaks in long-running applications.

The New Relic agent calls enterWith() directly in its context manager:

this._asyncLocalStorage.enterWith(newContext)

setContext(newContext) {
  this._asyncLocalStorage.enterWith(newContext)
}

With the new AsyncContextFrame implementation, enterWith() behaves differently than it did under the previous AsyncLocalStorage implementation. Specifically, AsyncResource instances no longer reflect enterWith() mutations made after their construction — they snapshot the storage at construction time. This behavioral change, combined with the frequent enterWith() calls the agent makes on every instrumented function, leads to context objects being retained longer than expected and accumulating in memory.

There is an upstream Node.js issue tracking this behavioral change: nodejs/node#58204.

Additionally, enterWith() is marked as Stability: 1 - Experimental in the [Node.js v24 documentation](https://nodejs.org/docs/latest-v24.x/api/async_context.html#asynclocalstorageenterwithstore), which suggests it may not be the most reliable foundation for context propagation going forward.

Steps to reproduce

  1. Run any Node.js application instrumented with newrelic on Node.js v24.0.0+
  2. Generate sustained traffic over time
  3. Observe heap memory growing continuously without being reclaimed by GC

The core issue can be demonstrated in isolation with this minimal script (from nodejs/node#58204):

const { AsyncLocalStorage, AsyncResource } = require('async_hooks')
const { strictEqual } = require('assert')

const storage = new AsyncLocalStorage()
storage.enterWith(1)
const ar = new AsyncResource('test')
ar.runInAsyncScope(() => {
  storage.enterWith(2)
  ar.runInAsyncScope(() => strictEqual(storage.getStore(), 2))
})
// Fails on Node 24: expected 2 but got 1

Expected behavior

Memory usage should remain stable over time, with context objects being properly garbage-collected after their associated transactions complete.

Actual behavior

Heap memory grows continuously. Context objects set via enterWith() are retained because the AsyncContextFrame implementation snapshots storage at AsyncResource construction time rather than reflecting later enterWith() mutations.

Environment

  • Node.js version: v24.0.0+
  • New Relic agent version: tested with v12.x and 13.x
  • OS: any

Temporary workaround

Starting the application with the --no-async-context-frame flag reverts to the previous AsyncLocalStorage implementation and avoids the leak:

node --no-async-context-frame app.js

Suggested fix

Consider replacing the enterWith() usage in async-local-context-manager.js with AsyncLocalStorage.run(), which is the stable API and works correctly under both the old and new AsyncContextFrame implementations. Alternatively, the Node.js docs suggest using tracingChannel as a modern replacement for patterns relying on enterWith() + AsyncResource.

Related issues

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status

    Triage Needed

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions