Repository navigation
@sentry/cloudflare v11: every instrumented Durable Object invocation stays open ~500 ms after it finishes (span streaming's segment-end timer, even when unsampled) #25489
Description
Is there an existing issue for this?
- I have checked for existing issues
How do you use Sentry?
Sentry SaaS (sentry.io)
Which SDK are you using?
@sentry/cloudflare
SDK Version
11.0.0 through 11.8.0 (measured on 11.6.0; the code is unchanged in 11.0.0 and 11.8.0). 10.76.2 is unaffected.
Framework Version
wrangler 4.148.0 / workerd 1.20261006.1, compatibility_date: 2025-08-13, nodejs_compat. Production shows the same thing: our alarm median went from about 25 ms on 10.76.2 to 505 ms on 11.6.0.
Link to Sentry event
N/A
Reproduction Example/SDK Setup
The options are the ones from the Wrangler setup docs. The DSN can be fake: nothing is sent at tracesSampleRate: 0.
import { DurableObject } from 'cloudflare:workers';
import * as Sentry from '@sentry/cloudflare';
const options = (env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0 }); // the docs use 1.0; any rate reproduces
class Base extends DurableObject {
async fetch(request) {
if (new URL(request.url).searchParams.has('alarm')) await this.ctx.storage.setAlarm(Date.now() + 50);
return new Response('ok');
}
async alarm() {}
async ping() {
return 'pong';
}
}
export const MyDO = Sentry.instrumentDurableObjectWithSentry(options, Base);
export default Sentry.withSentry((env) => ({ ...options(env), rpcTracePropagationBindings: ['MyDO'] }), {
async fetch(request, env) {
const stub = env.MyDO.get(env.MyDO.idFromName(crypto.randomUUID()));
if (new URL(request.url).searchParams.has('rpc')) return new Response(await stub.ping());
return stub.fetch(request);
},
});I measured invocation duration with a tail consumer, as the time from eventTimestamp to the tail event's arrival. In production, use wallTimeMs in Workers Logs.
Steps to Reproduce
- Run the Worker with
wrangler dev, or deploy it. - Call it plain (DO
fetch), with?rpc(DO RPC) and with?alarm(alarm). - Compare each invocation's wall time with the same code on 10.76.2.
Expected Result
Each invocation's wall time matches its handler's duration, as it does on 10.x.
Actual Result
Every Durable Object invocation that creates a span stays open for about 500 ms after the handler returns. That covers fetch, alarm, webSocketMessage, and RPC methods when trace metadata is propagated. (webSocketClose measured 0–1 ms, so I have left it out.) The 500 ms is added on top of the handler, not a minimum: a 1 s alarm takes 1.5 s. The hold ends early if another event reaches the same object first. CPU time is unchanged.
Durable Objects are billed for wall-clock duration, and the pending timer also blocks hibernation. So each burst of activity costs up to 500 ms of extra billed time, and a hibernating WebSocket object stays awake for 500 ms after every message.
Wall time per invocation. Each variant ran in its own Worker (the client is cached per isolate, so variants sharing a Worker leak options into each other); two to three runs each:
| Invocation | No Sentry | 10.76.2 | 11.6.0 | 11.6.0 traceLifecycle: 'static' |
|---|---|---|---|---|
DO fetch |
3 ms | 4–6 ms | 506 ms | 4 ms |
| DO alarm (no-op) | 2–3 ms | 6 ms | 504–508 ms | 5–12 ms |
| DO alarm (handler sleeps 1 s) | 1006 ms | 1007–1010 ms | 1507–1508 ms | 1007–1009 ms |
DO webSocketMessage (hibernation API) |
0–8 ms | 2–6 ms | 503–504 ms | 3–5 ms |
DO RPC, caller propagates (rpcTracePropagationBindings) |
– | – | 504–505 ms | – |
| DO RPC, no propagation (no span is created) | 1–2 ms | 1–2 ms | 4 ms | 4–5 ms |
DO fetch followed by an alarm 50 ms later |
4–6 ms | 5 ms | 53–54 ms | 6–9 ms |
Stateless Worker fetch (withSentry) |
3–18 ms | 5–23 ms | 4–14 ms | 11–16 ms |
Controls: a handler with no Sentry that leaves its own setTimeout(() => {}, 500) pending shows the same 505 ms. With tracesSampleRate unset (tracing off), 11.6.0 shows no hold. spanStreamingIntegration({ flushOnSegmentEnd: false }) removes it, as does traceLifecycle: 'static'. cacheClient: false does not help.
Additional Context
Cause:
- In v11,
ClientdefaultstraceLifecycleto'stream'(feat(core)!: Enable span streaming by default #22642), andServerRuntimeClientaddsspanStreamingIntegration(). - Since feat(core): Always flush streamed span traces after segment end by default #23714, that integration runs
safeUnref(setTimeout(() => buffer.flush(traceId), 500))on everyafterSegmentSpanEnd. - An unsampled root span is still a
SentrySpan, and ending it still emitsafterSegmentSpanEnd. The timer is therefore scheduled attracesSampleRate: 0too, and it flushes an empty buffer. - feat(core): Always flush streamed span traces after segment end by default #23714 says the server-side event-loop concern "is already handled by
safeUnrefon the timer". workerd timers have nounref(), though. A Durable Object invocation stays open until its pending timers fire, and Cloudflare's lifecycle docs say pendingsetTimeouttimers prevent hibernation and eviction and incur duration charges. Stateless Worker requests don't wait for timers, which is why only Durable Objects show this.
On Cloudflare the timer is redundant even when spans are sampled. In v11, wrapMethodWithSentry runs its teardown flush after the method span has ended. That flush emits flush, which drains the SpanBuffer. The 500 ms timer is never cleared, so it outlives the drain.
The delay was built for the browser (#19643): http.client spans stay open up to 300 ms waiting for resource timing, so they can end after their segment. #23714 then turned segment-end flushing on for servers too, so the 5 s buffer wouldn't build up memory under high throughput. Neither reason applies on Workers: nothing waits for resource timing, and spans are already flushed at the end of every invocation.
The timer is also invisible from user code, so the only symptom is wall time pinned about 500 ms above CPU time.
Possible fixes:
@sentry/cloudflareregistersspanStreamingIntegration({ flushOnSegmentEnd: false })by default, since the boundary flush andflushTraceSpansalready deliver the spans.- The integration skips the segment-end timer for unsampled segment spans, and for traces the buffer no longer holds.
- The Durable Object docs mention
traceLifecycle: 'static'until a fix ships.
Workaround: pass traceLifecycle: 'static' (or set SENTRY_TRACE_LIFECYCLE=static), or leave tracesSampleRate unset if you don't need tracing.
Metadata
Metadata
Assignees
Projects
- StatusShow more project fieldsNo status
Activity
linear-code commentedon Oct 18, 2026
JS-3954
JPeer264 commentedon Oct 19, 2026
Thanks for writing in. I saw that you opened up a PR, I'll check it out
fix(cloudflare): Don't hold Durable Object invocations open with the …