Skip to content

async_hooks: avoid extra frame copies in AsyncLocalStorage.run() - #67396

Open
gurgunday wants to merge 1 commit into
nodejs:mainfrom
gurgunday:perf/als-run-frame-copy
Open

gurgunday wants to merge 1 commit into
nodejs:mainfrom
gurgunday:perf/als-run-frame-copy

Conversation

@gurgunday

@gurgunday gurgunday commented Nov 10, 2026 •

Copy link
Copy Markdown
Member
Before After
als.run(i, noop) 100 ns 16 ns
als.run(i, noop), two other stores set 146 ns 35 ns
await als.run(i, async fn) with two awaits 235 ns 90 ns
benchmark/async_hooks/async-local-storage-run.js 7.5M ops/s 20.3M ops/s

AsyncLocalStorage.run() calls enterWith() twice, once to enter and once in finally to restore, and each call builds a new frame:

enterWith(data) {
const frame = new AsyncContextFrame(this, data);
AsyncContextFrame.set(frame);
}
run(data, fn, ...args) {
const prior = this.getStore();
if (ObjectIs(prior, data)) {
return ReflectApply(fn, null, args);
}
this.enterWith(data);
try {
return ReflectApply(fn, null, args);
} finally {
this.enterWith(prior);
}
}

Each new frame copies the current one through the Map iterator protocol:

class AsyncContextFrame extends SafeMap {
constructor(store, data) {
super(AsyncContextFrame.current());
this.set(store, data);
}

Frames are now copied with forEach() instead, and run() puts the previous frame back instead of building a new one when possible.

With the AsyncContextFrame implementation, every
AsyncLocalStorage.run() built two copies of the current frame: one to
enter the new value and another one in the finally block to put the
previous value back. Both copies went through the Map iterator
protocol, which calls next() and allocates an entry array per store.

Copy frames with forEach() instead, and when fn() neither replaced the
current frame (enterWith()) nor removed a store from it (disable()),
put back the frame that was current before run() instead of building
a new one. Otherwise keep building a new frame as before, so that
those changes stay visible after run() returns. getStore() now does a
single lookup unless the stored value is undefined.

A side effect is that after a top-level run() returns the current
frame is undefined again, as it was before run(), instead of a frame
that holds the previous value of the store. getStore() returns the
same values either way.

Measured locally:

  als.run(i, noop)                          100 ns ->  16 ns
  als.run(i, noop), two other stores set    145 ns ->  35 ns
  await als.run(i, async fn with 2 awaits)  250 ns ->  89 ns
  async_hooks/async-local-storage-run.js    7.0M  ->  20M ops/s

Assisted-by: claude:opus-5.5
Signed-off-by: Gürgün Dayıoğlu <hey@gurgun.day>
@nodejs-github-bot nodejs-github-bot added async_local_storage Issues and PRs related to the AsyncLocalStorage API. needs-ci PRs that need a full CI run. labels Nov 10, 2026
@gurgunday gurgunday added the performance Issues and PRs related to the performance of Node.js. label Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

async_local_storage Issues and PRs related to the AsyncLocalStorage API. needs-ci PRs that need a full CI run. performance Issues and PRs related to the performance of Node.js.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants