Summary
When an invocation ends, workerd tears down its cloudflare:sockets sockets without settling socket.closed or the readable stream's EOF promise. Any reaction registered on those promises stays pending forever. Because promise reactions capture the active AsyncContextFrame, a reaction registered inside an AsyncLocalStorage.run(...) scope permanently retains the whole stored context graph.
With a database driver that subscribes to these promises per connection (postgres.js's Cloudflare build does: closed.then(...), reader acquisition, a read loop with an always-pending reader.read()), every invocation leaks its full context β in our production-shaped worker, ~145 KB per scheduled invocation, monotonic through GC, until the 128 MB isolate limit.
Environment
- workerd via wrangler 4.84.1, local dev (reproduced)
- Production Workers (observed, identical slope)
nodejs_compat
- Compatibility date
2026-04-20
- Sockets opened through a Hyperdrive binding's connection string, using postgres.js 3.4.9
Retainer chain
From V8 heap snapshots, with a forced GC between samples; every hop verified:
(Global handles)
-> pending PromiseReaction
-> socket `closed` promise (held by its workerd Resolver / the stream's eofResolverPair)
-> captured AsyncContextFrame
-> StorageEntry
-> the AsyncLocalStorage value (our per-request context object)
-> everything it references
Reproduction sketch
In a worker with nodejs_compat:
import { AsyncLocalStorage } from 'node:async_hooks';
import { connect } from 'cloudflare:sockets';
const als = new AsyncLocalStorage();
export default {
async scheduled() {
await als.run({ big: new Uint8Array(100_000) }, async () => {
const socket = connect({ hostname: 'example.com', port: 5432 });
socket.closed.then(() => {});
// invocation ends without closing the socket explicitly
});
},
};
Let the invocation end without closing the socket explicitly (or even after close() in some teardown orders). Drive N invocations, force GC, take a snapshot: one pinned AsyncContextFrame per invocation, held by the pending reaction.
Registering the same handler through an AsyncLocalStorage.snapshot() captured at module scope (an empty frame) eliminates the growth β that is the workaround we ship today, as a patch to the driver.
Expected
Sockets destroyed at IoContext teardown settle their closed and EOF promises (rejecting with a teardown error, matching the behavior documented for I/O outliving a request), so reactions run and release their frames.
Impact
Any long-lived isolate β cron-driven Workers especially β using AsyncLocalStorage together with a socket-based driver leaks until OOM.
Possibly related: #5665 (unresolved promises retained in streaming paths) may be a sibling of the same teardown-settlement gap, though the growth here is monotonic across forced GCs and across invocations, rather than a matter of promise volume within a single request.
Summary
When an invocation ends, workerd tears down its
cloudflare:socketssockets without settlingsocket.closedor the readable stream's EOF promise. Any reaction registered on those promises stays pending forever. Because promise reactions capture the activeAsyncContextFrame, a reaction registered inside anAsyncLocalStorage.run(...)scope permanently retains the whole stored context graph.With a database driver that subscribes to these promises per connection (postgres.js's Cloudflare build does:
closed.then(...), reader acquisition, a read loop with an always-pendingreader.read()), every invocation leaks its full context β in our production-shaped worker, ~145 KB per scheduled invocation, monotonic through GC, until the 128 MB isolate limit.Environment
nodejs_compat2026-04-20Retainer chain
From V8 heap snapshots, with a forced GC between samples; every hop verified:
Reproduction sketch
In a worker with
nodejs_compat:Let the invocation end without closing the socket explicitly (or even after
close()in some teardown orders). Drive N invocations, force GC, take a snapshot: one pinnedAsyncContextFrameper invocation, held by the pending reaction.Registering the same handler through an
AsyncLocalStorage.snapshot()captured at module scope (an empty frame) eliminates the growth β that is the workaround we ship today, as a patch to the driver.Expected
Sockets destroyed at IoContext teardown settle their
closedand EOF promises (rejecting with a teardown error, matching the behavior documented for I/O outliving a request), so reactions run and release their frames.Impact
Any long-lived isolate β cron-driven Workers especially β using
AsyncLocalStoragetogether with a socket-based driver leaks until OOM.Possibly related: #5665 (unresolved promises retained in streaming paths) may be a sibling of the same teardown-settlement gap, though the growth here is monotonic across forced GCs and across invocations, rather than a matter of promise volume within a single request.