Summary
makeFlushLock in src/flush.ts re-wraps context.waitUntil on every call. Since init() runs on every fetch handled by an instrumented Durable Object, the
wrapper chain grows by one link per request on the same DurableObjectState (which is reused for the lifetime of the DO isolate). After enough requests this
manifests as:
RangeError: Maximum call stack size exceeded — every waitUntil(p) now recurses through N wrappers before reaching the native function.
Durable Object's isolate exceeded its memory limit and was reset. — each wrapper retains its own closure (pending, allDone, resolveAllDone, the
previous originalWaitUntil), so the per-context retained set grows linearly with request count.
We hit both in production on a Durable Object that handles ~thousands of fetches per day. The DO went silent (alarm crashed on every tick, no progress made)
until we manually cleared its accepted queue.
Affected versions
@sentry/cloudflare@10.53.1 (latest on npm at time of writing). The relevant code has not changed in recent releases — present at least since DO instrumentation
was added.
Root cause
src/flush.ts:
export function makeFlushLock(context: ExecutionContext): FlushLock {
// ...
const originalWaitUntil = context.waitUntil.bind(context) as typeof context.waitUntil;
context.waitUntil = promise => {
pending++;
return originalWaitUntil(
promise.finally(() => {
if (--pending === 0) resolveAllDone();
}),
);
};
// ...
}
This is called from init() in src/sdk.ts:
const flushLock = options.ctx ? makeFlushLock(options.ctx) : undefined;
And init() is called from wrapRequestHandler in src/request.ts, which is the per-fetch entrypoint for both regular Workers and instrumented DOs.
For regular Workers this is harmless — each request gets a fresh ExecutionContext. For Durable Objects the DurableObjectState (instrumented via
instrumentContext) is the same object instance across every fetch on a given DO instance, so each init() reads the previous wrapper as the new originalWaitUntil,
and installs a new wrapper on top. Stack depth and retained closures grow without bound.
Reproduction
Minimal repro (haven't isolated this into a standalone repo, but the shape is straightforward):
- Instrument a Durable Object with instrumentDurableObjectWithSentry.
- From the DO, accept a few thousand fetch requests (e.g., a long-lived queue actor).
- Observe in wrangler tail that exceptions start firing:
RangeError: Maximum call stack size exceeded
at context2.waitUntil (main.js:11092:24)
at context2.waitUntil (main.js:11094:12)
at context2.waitUntil (main.js:11094:12)
at context2.waitUntil (main.js:11094:12)
...
The repeated context2.waitUntil frames at main.js:11094 (the inner originalWaitUntil(...) call inside the wrapper, recursing into the previous wrapper which is
context2.waitUntil from the perspective of bind) are the smoking gun.
Eventually:
Error: Durable Object's isolate exceeded its memory limit and was reset.
Why this isn't a problem in regular Workers
In a normal Worker, ExecutionContext is per-request and discarded after the response. The wrapper chain is at most one deep per request, which is fine. Only
DurableObjectState, which is reused across the DO instance's lifetime, exposes the leak.
Suggested fix
Install the wrapper at most once per context, and have each makeFlushLock call register its own pending tracker into a shared registry that the single wrapper
fans out to. Sketch:
const WRAPPER_MARK = Symbol.for('@sentry/cloudflare/flush-wrapper');
export function makeFlushLock(context: ExecutionContext): FlushLock {
let resolveAllDone: () => void = () => undefined;
const allDone = new Promise<void>(res => { resolveAllDone = res; });
let pending = 0;
let resolved = false;
const installed = (context.waitUntil as any)?.[WRAPPER_MARK];
let registry: Set<Lock>;
if (installed) {
registry = installed.registry;
} else {
registry = new Set();
const trueOriginal = context.waitUntil.bind(context);
const wrapper = ((promise: Promise<unknown>) => {
const snapshot = [...registry];
for (const l of snapshot) l.acquire();
return trueOriginal(promise.finally(() => {
for (const l of snapshot) l.release();
}));
}) as typeof context.waitUntil;
(wrapper as any)[WRAPPER_MARK] = { registry, trueOriginal };
context.waitUntil = wrapper;
}));
}) as typeof context.waitUntil;
(wrapper as any)[WRAPPER_MARK] = { registry, trueOriginal };
context.waitUntil = wrapper;
}
const lock: Lock = {
acquire: () => { if (!resolved) pending++; },
release: () => {
if (resolved) return;
if (--pending === 0) {
resolved = true;
resolveAllDone();
registry.delete(lock);
}
},
};
registry.add(lock);
return Object.freeze({
ready: allDone,
finalize: () => {
if (!resolved && pending === 0) {
resolved = true;
resolveAllDone();
registry.delete(lock);
}
return allDone;
},
});
}
interface Lock { acquire(): void; release(): void; }
- Stack depth constant at 2 (wrapper → native).
- One closure per active flushLock; closures GC'd as soon as a lock's pending returns to 0 (lock is removed from the registry on release/finalize).
- Per-flushLock pending semantics preserved: a lock added during request A counts every waitUntil issued from its registration until its own pending returns to
0, which matches the previous (chained) behavior.
I've shipped this as a yarn patch in our codebase and confirmed it eliminates both the RangeError and the exceededMemory reports from our DO. Happy to send a PR
if it'd be useful.
Environment
- @sentry/cloudflare: 10.53.1
- Cloudflare Workers + Durable Objects (SQLite-backed)
- nodejs_compat compatibility flag
- compatibility_date: 2025-07-01
Summary
makeFlushLockinsrc/flush.tsre-wrapscontext.waitUntilon every call. Sinceinit()runs on every fetch handled by an instrumented Durable Object, thewrapper chain grows by one link per request on the same
DurableObjectState(which is reused for the lifetime of the DO isolate). After enough requests thismanifests as:
RangeError: Maximum call stack size exceeded— everywaitUntil(p)now recurses through N wrappers before reaching the native function.Durable Object's isolate exceeded its memory limit and was reset.— each wrapper retains its own closure (pending,allDone,resolveAllDone, theprevious
originalWaitUntil), so the per-context retained set grows linearly with request count.We hit both in production on a Durable Object that handles ~thousands of fetches per day. The DO went silent (alarm crashed on every tick, no progress made)
until we manually cleared its accepted queue.
Affected versions
@sentry/cloudflare@10.53.1(latest on npm at time of writing). The relevant code has not changed in recent releases — present at least since DO instrumentationwas added.
Root cause
src/flush.ts:This is called from init() in src/sdk.ts:
const flushLock = options.ctx ? makeFlushLock(options.ctx) : undefined;And init() is called from wrapRequestHandler in src/request.ts, which is the per-fetch entrypoint for both regular Workers and instrumented DOs.
For regular Workers this is harmless — each request gets a fresh ExecutionContext. For Durable Objects the DurableObjectState (instrumented via
instrumentContext) is the same object instance across every fetch on a given DO instance, so each init() reads the previous wrapper as the new originalWaitUntil,
and installs a new wrapper on top. Stack depth and retained closures grow without bound.
Reproduction
Minimal repro (haven't isolated this into a standalone repo, but the shape is straightforward):
The repeated context2.waitUntil frames at main.js:11094 (the inner originalWaitUntil(...) call inside the wrapper, recursing into the previous wrapper which is
context2.waitUntil from the perspective of bind) are the smoking gun.
Eventually:
Error: Durable Object's isolate exceeded its memory limit and was reset.Why this isn't a problem in regular Workers
In a normal Worker, ExecutionContext is per-request and discarded after the response. The wrapper chain is at most one deep per request, which is fine. Only
DurableObjectState, which is reused across the DO instance's lifetime, exposes the leak.
Suggested fix
Install the wrapper at most once per context, and have each makeFlushLock call register its own pending tracker into a shared registry that the single wrapper
fans out to. Sketch:
0, which matches the previous (chained) behavior.
I've shipped this as a yarn patch in our codebase and confirmed it eliminates both the RangeError and the exceededMemory reports from our DO. Happy to send a PR
if it'd be useful.
Environment