Skip to content

perf(cloudflare): withSentry adds ~450ms per-request overhead to CORS preflight (OPTIONS) requests because handler.fetch is mutated in place #21022

Closed

Description

@dim0627

Is there an existing issue for this?

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

  1. Deploy the above Worker behind a custom domain.
  2. Configure a cross-origin frontend (different domain) that triggers a GET /me from the browser.
  3. Disable cache, hard-reload the frontend, and observe DevTools Network.
  4. Measure the OPTIONS preflight time_starttransfer (TTFB) via curl from 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
    
  5. 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-level fetch).

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) in packages/cloudflare/src/withSentry.ts calls instrumentExportedHandlerFetch(handler, optionsCallback) and then returns handler (the same reference).

  • instrumentExportedHandlerFetch in packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts does:

    handler.fetch = ensureInstrumented(
      handler.fetch,
      original => new Proxy(original, { apply(target, thisArg, args) { ... wrapRequestHandler(...) } }),
    );

    This mutates the fetch property of the user's handler in place. Because app === sentryWrapped (same reference), both app.fetch(...) and sentryWrapped.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 — including OPTIONS. While it does skip span creation for OPTIONS / 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, addCultureContext
    • finally { 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:

  1. The in-place mutation of handler.fetch is not documented. Looking at the public surface of withSentry(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 the withSentry JSDoc and the Cloudflare docs would prevent this surprise.

  2. A shouldHandle(request, env, ctx) hook on withSentry options 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 shouldHandle returns false, the SDK could short-circuit before wrapRequestHandler runs and call the original fetch directly.

  3. 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.

Activity

  1. linear-code commented on May 20, 2026

    @linear-code
  2. moved this to Waiting for: Product Owner in GitHub Issues with 👀 3on May 20, 2026
  3. andreiborza commented on May 20, 2026

    @andreiborza
    Member

    Hi @dim0627, thanks for filing. I passed this on to @JPeer264 who'll take a look.

  4. moved this from Waiting for: Product Owner to No status in GitHub Issues with 👀 3on May 20, 2026
  5. moved this to Waiting for: Product Owner in GitHub Issues with 👀 3on May 20, 2026
  6. JPeer264 commented on May 20, 2026

    @JPeer264
    Member

    Thanks a lot for the issue. We are already ignoring this, but it seems it is way too late:

    I'll try to see where we can move this and improve performance here.

    FWIW in Bun we return as early as possble:

    if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') {
    return target.apply(thisArg, args);
    }

  7. moved this from Waiting for: Product Owner to No status in GitHub Issues with 👀 3on May 20, 2026
  8. dim0627 commented on May 20, 2026

    @dim0627
    Author

    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.

  9. moved this to Waiting for: Product Owner in GitHub Issues with 👀 3on May 20, 2026
  10. JPeer264 commented on May 21, 2026

    @JPeer264
    Member

    @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

  11. moved this from Waiting for: Product Owner to No status in GitHub Issues with 👀 3on May 21, 2026
  12. added a commit that references this issue on May 21, 2026
    bdb0e41
  13. github-actions commented on May 26, 2026

    @github-actions
    Contributor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions