Repository navigation
perf(cloudflare): withSentry adds ~450ms per-request overhead to CORS preflight (OPTIONS) requests because handler.fetch is mutated in place #21022
Description
Is there an existing issue for this?
- I have checked for existing issues https://github.com/getsentry/sentry-javascript/issues
- I have reviewed the documentation https://docs.sentry.io/
- I am using the latest SDK release https://github.com/getsentry/sentry-javascript/releases
How do you use Sentry?
Sentry Saas (sentry.io)
Which SDK are you using?
@sentry/cloudflare
SDK Version
10.52.0 (also reproduced by code inspection on master)
Framework Version
Hono 4.12.18 on Cloudflare Workers (nodejs_compat flag enabled)
Link to Sentry event
N/A — this is a per-request init overhead issue, not a captured event.
Reproduction Example/SDK Setup
Minimal Worker handler:
import * as Sentry from "@sentry/cloudflare";
import { Hono } from "hono";
import { cors } from "hono/cors";
const app = new Hono();
app.use("*", cors({ origin: "https://example.com", maxAge: 86400 }));
app.get("/me", (c) => c.json({ ok: true }));
const sentryWrapped = Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
enabled: !!env.SENTRY_DSN,
}),
// Hono exposes a fetch handler via `app`
{ fetch: app.fetch } as ExportedHandler<Env>,
);
export default {
fetch(request, env, ctx) {
// Intent: bypass Sentry for CORS preflight (OPTIONS) — they are
// synthetic, never application logic, and 1 per cross-origin endpoint
// every `maxAge` window.
if (request.method === "OPTIONS") {
return app.fetch(request, env, ctx); // <-- intended bypass
}
return sentryWrapped.fetch!(request, env, ctx);
},
} satisfies ExportedHandler<Env>;Steps to Reproduce
- Deploy the above Worker behind a custom domain.
- Configure a cross-origin frontend (different domain) that triggers a
GET /mefrom the browser. - Disable cache, hard-reload the frontend, and observe DevTools Network.
- Measure the OPTIONS preflight
time_starttransfer(TTFB) viacurlfrom a warm region:curl -X OPTIONS https://your-worker.example.com/me \ -H "Origin: https://example.com" \ -H "Access-Control-Request-Method: GET" \ -w "ttfb=%{time_starttransfer}s total=%{time_total}s\n" \ -o /dev/null -s - Compare against a baseline route that bypasses both Hono and
withSentry(e.g.if (request.method === "GET" && url.pathname === "/health") return new Response(...)returned from the top-levelfetch).
Expected Result
Since OPTIONS is short-circuited before sentryWrapped.fetch is called, the Sentry SDK should not run for these requests. The preflight should complete at the same latency as the baseline (/health ≈ 18ms after TLS warmup in our measurements).
Actual Result
The intended app.fetch(request, env, ctx) bypass is effectively a no-op — preflight requests still take ~466ms TTFB while a sibling /health returned from the top-level fetch takes ~18ms in the same warm session, a ~448ms gap on every OPTIONS that misses the browser's preflight cache.
Investigation (code path):
-
withSentry(opts, handler)inpackages/cloudflare/src/withSentry.tscallsinstrumentExportedHandlerFetch(handler, optionsCallback)and then returnshandler(the same reference). -
instrumentExportedHandlerFetchinpackages/cloudflare/src/instrumentations/worker/instrumentFetch.tsdoes:handler.fetch = ensureInstrumented( handler.fetch, original => new Proxy(original, { apply(target, thisArg, args) { ... wrapRequestHandler(...) } }), );
This mutates the
fetchproperty of the user's handler in place. Becauseapp === sentryWrapped(same reference), bothapp.fetch(...)andsentryWrapped.fetch!(...)resolve to the same Sentry Proxy. The "OPTIONS bypasses Sentry" branch in user code does nothing. -
Once routed through that Proxy,
wrapRequestHandler(packages/cloudflare/src/request.ts) executes on every request — includingOPTIONS. While it does skip span creation forOPTIONS/HEAD, it still runs:init({...options, ctx})— building 9 integrations, OpenTelemetry tracer setup,initAndBind(CloudflareClient, ...)withIsolationScope(...)to scope the isolation- URL parsing (
parseStringToURLObject,getHttpSpanDetailsFromUrlObject,httpHeadersToSpanAttributes) addCloudResourceContext,addRequest,addCultureContextfinally { waitUntil(flushAndDispose(client)) }(background flush, but the client construction itself is synchronous on the request path)
That collectively accounts for the ~448ms observed delta. Sampling-level controls (tracesSampler: () => 0, ignoreTransactions: [/^OPTIONS\s/], beforeSendTransaction) do not help — they filter outgoing data, not the per-request init cost.
We worked around this by skipping withSentry entirely for OPTIONS requests at the very top of the Worker handler — returning 204 with a hand-rolled set of CORS headers equivalent to hono/cors. Result on production after deploy:
| Endpoint | Before | After |
|---|---|---|
| OPTIONS (preflight, isolated request) | ~466ms TTFB | ~11ms TTFB |
/health (baseline) |
18ms | 18ms |
Real-world traffic also improved because we had already enabled maxAge: 86400, dramatically reducing preflight frequency — but each preflight that did fire was still paying the per-request init cost.
Additional Context
A few observations and concrete suggestions:
-
The in-place mutation of
handler.fetchis not documented. Looking at the public surface ofwithSentry(opts, handler) => handler, it is reasonable for users to assume the returned wrapper is the only instrumented entry point and that the original handler reference is untouched. A short note in thewithSentryJSDoc and the Cloudflare docs would prevent this surprise. -
A
shouldHandle(request, env, ctx)hook onwithSentryoptions would make the "skip OPTIONS / health / etc." pattern a single line of user code and avoid the duplication of CORS header construction. Something like:Sentry.withSentry( (env) => ({ dsn: env.SENTRY_DSN, shouldHandle: (request) => request.method !== "OPTIONS", }), app, );
When
shouldHandlereturnsfalse, the SDK could short-circuit beforewrapRequestHandlerruns and call the originalfetchdirectly. -
Alternative: lazy-initialize the
init()/ OpenTelemetry setup only when a span is actually going to be created. The OPTIONS / HEAD short-circuit already skips span creation; pulling the heavy init behind that check would dramatically reduce per-request overhead for these requests with no API change.
Happy to send a PR if there's appetite — would prefer to get directional guidance from the maintainers first since this touches the core wrapper design.
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsNo status
Activity
linear-code commentedon May 20, 2026
JS-2553
andreiborza commentedon May 20, 2026
Hi @dim0627, thanks for filing. I passed this on to @JPeer264 who'll take a look.
JPeer264 commentedon May 20, 2026
Thanks a lot for the issue. We are already ignoring this, but it seems it is way too late:
sentry-javascript/packages/cloudflare/src/request.ts
Line 91 in abfd7c3
I'll try to see where we can move this and improve performance here.
FWIW in Bun we return as early as possble:
sentry-javascript/packages/bun/src/integrations/bunserver.ts
Lines 182 to 184 in abfd7c3
dim0627 commentedon May 20, 2026
Thanks for looking into this so quickly! If it would help, I'm happy to put together a PR — just let me know the preferred approach once you've had a chance to think through where to move the early-return check. Happy to follow your lead on the design.
JPeer264 commentedon May 21, 2026
@dim0627 thanks for your offer, by the time you wrote the fix was already done. I was just playing around with a proper integration test, but even locally they were unreliable in response times - so we just rely on a unit test for this now. The PR has been opened
fix(cloudflare): Skip SDK initialization for OPTIONS/HEAD requests (g…
github-actions commentedon May 26, 2026
A PR closing this issue has just been released 🚀
This issue was referenced by PR #21090, which was included in the 10.54.0 release.