perf(web): stop blocking every API request on a Clerk token fetch - #340
Merged
Conversation
Production telemetry showed ~580ms p50 / 3.2s p90 elapsing between the browser issuing a request and the API Worker's server span starting, against a 204ms median handler (n=1485 maple-web -> maple-api traces). The gap was uniform across every endpoint in the app, which is the signature of a fixed per-request cost rather than per-query work. It was `mapleFetch` awaiting an uncached Clerk `getToken()` before every outbound fetch. Clerk session JWTs live 60s, so most page loads paid a full cross-origin round-trip to Clerk before the first byte went to the API. - auth-headers now caches the resolved bearer until it is nearly spent and refreshes ahead of expiry, so no request blocks on the identity provider. The refresh is single-flighted, and a generation counter discards one that resolves after the identity changed. - The cache is dropped on org switch: the JWT encodes the active org, so a token outliving a switch would query the previous org's data. - Only JWT-shaped bearers are cached. An opaque token (self-hosted, read synchronously from sessionStorage) has no expiry we can trust. - A 401 now invalidates the cache and retries once, so a token that goes stale ahead of its own `exp` self-heals instead of failing the page. Also, on the session replays list specifically: - Add a route loader mounting both queries. The router runs defaultPreload: "intent", so they now start on hover rather than after hydration and the route chunk evaluate. The filter-input builder is extracted and tested so the loader and the component provably key to the same atom entry -- a mismatch would silently double the requests. - Narrow the org ClickHouse settings hot-path read from SELECT * (14 columns including encrypted password blobs) to the 8 that are actually cached. This query runs on every warehouse execution and was measured at p50 2.1s in the API Worker. - Drop a comment claiming EdgeCacheService single-flights in-flight computes; it deliberately does not, because Cloudflare ties I/O objects to the request that created them. isWarehouseWriteReady deliberately keeps its uncached read. Routing it through the 5-minute memo made a freshly-connected BYO-CH org report not-write-ready for minutes after onboarding, sending reads to Tinybird while the gateway already writes to ClickHouse.
Contributor
|
Your Pullfrog Router balance is exhausted. You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer. Top up balance → · Enable auto-reload →
|
🍁 Maple PR previewNote Preview resources were removed when this pull request closed. Final commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Why
The session replays list felt slow. It turned out the replay queries weren't the problem — the page fires exactly two requests, and the ClickHouse work behind them is ~400 ms and ~140 ms.
Measuring the per-trace gap between the browser's client span and the API Worker's server span, across all maple-web → maple-api traces on 2026-08-04 (n=1485):
Not clock skew (per trace,
pre_handler + handler ≈ client span duration) and not retries (POST is excluded from transport retry, and the traces carry exactly one server span). It's a fixed per-request cost, uniform across every endpoint in the app —/query-engine/executep50 1109 ms,/billing/customerp50 828 ms,/auth/sessionp50 807 ms, all against ~200 ms handlers.The one thing on that path for every request:
mapleFetchawaitedgetMapleAuthHeaders(), which resolved to Clerk'sgetToken()with no caching at any layer. Clerk session JWTs live 60 s, so a page loaded more than a minute after the last request paid a full cross-origin round-trip to Clerk before the first byte went to the API.What changed
Token cache (
auth-headers.ts) — the main fix, and it affects every request in the app, not just replays.sessionStorage) has no expiry we can trust, so that path is unchanged.atom-client.ts), so a token stale ahead of its ownexpself-heals. Bounded per-request via aWeakSetso concurrent 401s don't consume each other's allowance, and a second 401 still fails fast.Route loader on
/replays— the router runsdefaultPreload: "intent", so both queries now start on hover rather than after hydration + route-chunk evaluate. The detail route already did this; the list route didn't.Narrowed the org ClickHouse settings hot-path read —
SELECT *(14 columns, including encrypted password blobs) → the 8 that are actually cached. This query runs on every warehouse execution and measures p50 2098 ms / p99 6002 ms in the API Worker (vs 313 ms for identical code inalerting). It is a child ofexecuteSql, which is why nearly everyquery.contextin the catalogue shares a ~3 s p95 regardless of what it scans.Also removed a comment asserting
EdgeCacheServicesingle-flights in-flight computes — it deliberately doesn't, because Cloudflare ties I/O objects to the request that created them.Reviewer notes
The key-drift risk in the loader is tested, not assumed. A prefetch only pays off if the loader and the component key to the same atom-family entry; a mismatch would silently double the requests and nothing else would notice. The filter-input builder is extracted to
replays-filter-inputs.tsandreplays-filter-inputs.test.tspins the invariant — including a fake-timer test proving the keys still match 14 s apart, sinceencodeKeysnaps timestamps to a 15 s grid.isWarehouseWriteReadydeliberately keeps its uncached read. Routing it through the shared 5-minute in-isolate memo failedCloudflareAnalyticsService.test.ts, correctly: that gate decides which warehouse a read is answered from, and a stalefalseright after onboarding sends reads to Tinybird while the gateway is already writing to the org's ClickHouse — data silently missing until the memo expires. It reads the narrow projection now, but it reads it fresh.The
auth.tokenspan was dropped. A proper child span needs header injection moved out of the fetch shim and into the Effect client pipeline, which risks a request path going out unauthenticated for a measurement nicety. The pre-handler-gap query below measures the same thing from telemetry that already exists.Not in this PR
Two items with trade-offs that need a call rather than a default:
resolveRouteto once per request. Still the right fix for the shared ~3 s p95, butOrgClickHouseSettingsServiceis built per isolate, not per request, so a naive in-flight map hits the exact "Cannot perform I/O on behalf of a different request" failure the edge cache documents. Needs request-scoped state threaded through the execution core every query runs through.refreshVersionnever reaches the list query — so a server TTL could return a byte-identical response to an explicit refresh.Verification
bun typecheckclean for both apps. 216 web tests and 87 API tests pass (scoped to touched areas).After deploy, the decisive before/after — this needs no new instrumentation:
Watch
pre_p50fall from 580 ms toward the network floor, andSessionReplays.listReplays/.facetsclient-observed p50 toward handler-time-plus-network.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.