Skip to content

@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

Closed

Description

@philipatkinson

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

  1. Run the Worker with wrangler dev, or deploy it.
  2. Call it plain (DO fetch), with ?rpc (DO RPC) and with ?alarm (alarm).
  3. 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:

  1. In v11, Client defaults traceLifecycle to 'stream' (feat(core)!: Enable span streaming by default #22642), and ServerRuntimeClient adds spanStreamingIntegration().
  2. Since feat(core): Always flush streamed span traces after segment end by default #23714, that integration runs safeUnref(setTimeout(() => buffer.flush(traceId), 500)) on every afterSegmentSpanEnd.
  3. An unsampled root span is still a SentrySpan, and ending it still emits afterSegmentSpanEnd. The timer is therefore scheduled at tracesSampleRate: 0 too, and it flushes an empty buffer.
  4. feat(core): Always flush streamed span traces after segment end by default #23714 says the server-side event-loop concern "is already handled by safeUnref on the timer". workerd timers have no unref(), though. A Durable Object invocation stays open until its pending timers fire, and Cloudflare's lifecycle docs say pending setTimeout timers 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/cloudflare registers spanStreamingIntegration({ flushOnSegmentEnd: false }) by default, since the boundary flush and flushTraceSpans already 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.

Activity

  1. linear-code commented on Oct 18, 2026

    @linear-code
  2. moved this to Waiting for: Product Owner in GitHub Issues with 👀 3on Oct 18, 2026
  3. JPeer264 commented on Oct 19, 2026

    @JPeer264
    Member

    Thanks for writing in. I saw that you opened up a PR, I'll check it out

  4. moved this from Waiting for: Product Owner to No status in GitHub Issues with 👀 3on Oct 19, 2026
  5. added a commit that references this issue on Oct 19, 2026
    b8cfdda
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions