Repository navigation
π Bug Report β Runtime APIs: AsyncLocalStorage store is never collected when it references a pending promise with a reaction attached inside run()Β #7611
Description
Summary
An AsyncLocalStorage store is never garbage collected on workerd when the store references a promise that never settles and a reaction (.then, await, Promise.race) is attached to that promise inside the als.run() scope. Node 24 collects the same code. The store, and everything it references, stays alive for the life of the isolate.
Smallest case
als.run(store, () => {
store.pending.then(() => {}); // store.pending = new Promise(() => {})
});All four conditions are needed. Removing any one makes the store collectable:
AsyncLocalStorageis in use- the reaction is attached inside the
als.run()scope (attaching it outside the scope collects) - the pending promise is reachable from the store (a pending promise the store cannot reach collects)
- the promise never settles (resolving it collects)
An async function awaiting the store's pending promise, a Promise.race against it, and a .then on it all retain. The payload size and the resolver closure don't matter.
Measurements
workerd 2026-09-23 (npm workerd@1.20260923.1), compatibility date 2026-09-01, flag nodejs_als, v8Flags = ["--expose-gc"], macOS arm64. Node v24.11.0 for the comparison, running the same worker.js. Each route creates many stores with a payload, each registered in a FinalizationRegistry. /stats then runs gc() and reports how many were never finalized ("retained").
| Route | What it does | workerd retained | Node 24 retained |
|---|---|---|---|
leak |
ALS store, Promise.race([work, interrupted]), interrupted never settles and the store reaches it |
all | none |
minimal |
als.run(store, () => store.pending.then(noop)) |
all | none |
settled |
same as leak, interrupted resolved in finally |
none | none |
no-reaction |
store holds the pending promise, nothing reacts to it | none | none |
no-als |
same race, no AsyncLocalStorage |
none | none |
I did not test other workerd versions. The instrument fails loudly if gc() is missing or a route creates the wrong number of stores.
The mechanism (the store, the pending promise, its reaction, the async context frame, the frame's storage value, back to the store) is inferred from the variants above. I did not read workerd's source.
Real-world impact
vinext's App Router connection probe has exactly this shape on every page render, so vinext dev retains each render's store until the process runs out of memory: cloudflare/vinext#3693. Settling the promise in a finally fixes the app-side symptom, but any code with this shape leaks on workerd and not on Node.
Reproduce
Three files, in one directory. Run:
npx workerd serve workerd.capnp & # listens on 127.0.0.1:8799
node run.mjs http://127.0.0.1:8799 # workerd: leak and minimal retain every store, controls retain none
node --expose-gc run.mjs node # Node 24: every route retains none
worker.js
import { AsyncLocalStorage } from "node:async_hooks";
const als = new AsyncLocalStorage();
const PAYLOAD_BYTES = 100 * 1024;
const stats = {};
const registry = new FinalizationRegistry((route) => {
stats[route].finalized++;
});
function makeStore(route) {
stats[route] ??= { created: 0, finalized: 0 };
stats[route].created++;
// `interrupted` never settles unless store.interrupt() is called.
let interrupt;
const interrupted = new Promise((resolve) => {
interrupt = () => resolve("interrupted");
});
const store = {
payload: new Uint8Array(PAYLOAD_BYTES).fill(1),
interrupt,
pending: new Promise(() => {}),
};
registry.register(store, route);
return { store, interrupted };
}
const work = () => Promise.resolve().then(() => "done");
const routes = {
// ALS store + race against a never-settling promise the store can reach.
async leak() {
const { store, interrupted } = makeStore("leak");
await als.run(store, async () => await Promise.race([work(), interrupted]));
},
// Smallest case: one .then on a never-settling promise the store holds, attached inside als.run.
async minimal() {
const { store } = makeStore("minimal");
als.run(store, () => {
store.pending.then(() => {});
});
},
// Same, but `interrupted` is settled in finally.
async settled() {
const { store, interrupted } = makeStore("settled");
await als.run(store, async () => {
try {
await Promise.race([work(), interrupted]);
} finally {
store.interrupt();
}
});
},
// Store holds the pending promise but nothing reacts to it.
async "no-reaction"() {
const { store } = makeStore("no-reaction");
await als.run(store, async () => await work());
},
// Same race, no AsyncLocalStorage.
async "no-als"() {
const { interrupted } = makeStore("no-als");
await (async () => await Promise.race([work(), interrupted]))();
},
};
async function settle() {
for (let i = 0; i < 5; i++) {
gc();
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
export default {
async fetch(request) {
if (typeof gc !== "function") {
throw new Error("gc() is not exposed: run with --expose-gc / v8Flags");
}
const path = new URL(request.url).pathname.slice(1);
if (path === "stats") {
await settle();
const out = {};
for (const [route, s] of Object.entries(stats)) {
out[route] = { ...s, retained: s.created - s.finalized };
}
return Response.json(out);
}
if (!Object.hasOwn(routes, path)) return new Response("not found", { status: 404 });
await routes[path]();
return new Response("ok");
},
};workerd.capnp
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [(name = "main", worker = .worker)],
sockets = [(name = "http", address = "127.0.0.1:8799", http = (), service = "main")],
v8Flags = ["--expose-gc"],
);
const worker :Workerd.Worker = (
modules = [(name = "worker", esModule = embed "worker.js")],
compatibilityDate = "2026-09-01",
compatibilityFlags = ["nodejs_als"],
);run.mjs
// Usage: node --expose-gc run.mjs node (runs worker.js in-process on Node)
// node run.mjs http://127.0.0.1:8799 (drives a running workerd)
import worker from "./worker.js";
const N = Number(process.env.N ?? 1000);
const ROUTES = (process.env.ROUTES ?? "leak,minimal,settled,no-reaction,no-als").split(",");
const target = process.argv[2];
if (!target) throw new Error("usage: run.mjs node | run.mjs <workerd base url>");
const call =
target === "node"
? (p) => worker.fetch(new Request(`http://x/${p}`))
: (p) => fetch(`${target}/${p}`);
async function ok(pending) {
const res = await pending;
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res;
}
for (const route of ROUTES) for (let i = 0; i < N; i++) await ok(call(route));
const stats = await (await ok(call("stats"))).json();
console.log(`${target === "node" ? process.version : "workerd"}, ${N} requests per route`);
console.table(stats);
for (const r of ROUTES) {
if (stats[r].created !== N) throw new Error(`${r}: created ${stats[r].created} != ${N}`);
}Expected
The store becomes collectable once nothing outside the isolate-internal async context machinery references it, as on Node 24.
Activity