From a81ccd7e696f5b75d7e320db18c1718d46a8fdc0 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:04:36 -0700 Subject: [PATCH] Warm the Start server graph on isolate first fetch Page latency was p50 19-49ms daily up to 2026-08-16 and 2728ms the day after the 21:20 deploy. Not load: 2026-08-11 served more total traffic (5.43M vs 4.38M) with pages at p50 41ms, so the volume explanation does not hold. What changed is where MCP work runs. The old hibernatable Agent bridge handed /mcp to the DO almost immediately; the v2 stack authenticates and dispatches in the worker, spinning far more worker isolates. /mcp returns before fetchHandler, so those isolates never load the Start graph, and page requests land on them cold paying loadEntries: p50 3.1s, against 33ms for the request's own work and 9-104ms warm. Re-lands #1628. Its revert came during the Aug 17 storm (11.67M requests against a 2.6-4.3M baseline); volume is back to baseline and exceededMemory is currently zero. START_GRAPH_WARM=false disables it. --- apps/cloud/src/env-augment.d.ts | 3 ++ apps/cloud/src/server.ts | 51 +++++++++++++++++++++++ apps/cloud/src/start-virtual-entries.d.ts | 8 ++++ 3 files changed, 62 insertions(+) create mode 100644 apps/cloud/src/start-virtual-entries.d.ts diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index cb8070ae6..88b300ccb 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -95,6 +95,9 @@ declare global { MCP_REQUEST_STATE_KEY?: string; /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */ MCP_2026_07_28_ENABLED?: string; + // Kill switch for the Start server-graph warmup (server.ts). Set to + // "false" to disable without a deploy if memory pressure returns. + START_GRAPH_WARM?: string; NODE_ENV?: string; // Shared with frontend diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 0304ab024..11e88cb5c 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -178,8 +178,59 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({ traceRequest: traceCloudMcpRequest, }); +// --------------------------------------------------------------------------- +// Start server-graph warmup +// --------------------------------------------------------------------------- +// +// Page latency was p50 19-49ms every day up to 2026-08-16 and 2728ms the day +// after the 21:20 UTC deploy. It is not load: 2026-08-11 served MORE total +// traffic (5.43M requests vs 4.38M) with pages at p50 41ms, so the +// reconnect-storm/volume explanation does not hold. +// +// What changed is where MCP work runs. The old hibernatable Agent bridge +// handed `/mcp` to the Durable Object almost immediately; the v2 stack +// authenticates and dispatches in the WORKER (`mcp.auth.jwt_verify` alone runs +// ~35k times per 6h there). That spins up far more worker isolates, and `/mcp` +// returns above without ever touching `fetchHandler` — so those isolates never +// load the Start graph. Page requests then land on them cold and pay +// `loadEntries`: measured p50 **3.1s**, against p50 33ms for the request's own +// work and 9-104ms for a warm isolate. The same bundle in local workerd serves +// the same path in ~3ms, so this is a cold-isolate cost, not slow code. +// +// So warm on the isolate's FIRST fetch, in the background: MCP traffic then +// pre-warms an isolate before a page request reaches it. This is #1628, which +// worked; it was reverted (#1634) when memory-limit kills jumped, but that +// landed during the 2026-08-17 storm — 11.67M requests in a day against a +// 2.6-4.3M baseline. Volume is back to baseline and `exceededMemory` is +// currently zero, so the condition that made it expensive is gone. +// `START_GRAPH_WARM=false` disables it without a deploy. +// +// Deliberately NOT at module scope: a full warmup there trips workerd's +// global-scope I/O restriction, and DO-only isolates should not carry the SSR +// graph. +// --------------------------------------------------------------------------- + +let startGraphWarmupStarted = false; + +const warmStartGraph = (env: Env): void => { + if (startGraphWarmupStarted) return; + if (env.START_GRAPH_WARM === "false") return; + startGraphWarmupStarted = true; + // oxlint-disable-next-line executor/no-promise-catch -- adapter boundary; fire-and-forget warmup outside any Effect runtime + void Promise.all([import("#tanstack-router-entry"), import("#tanstack-start-entry")]).catch( + () => { + // Advisory only — the request path still loads the graph lazily. + startGraphWarmupStarted = false; + }, + ); +}; + const cloudflareHandler: ExportedHandler = { fetch: async (request, env, ctx) => { + // First fetch in this isolate kicks the graph load off in the background, + // including for /mcp — MCP traffic is what reaches these isolates first. + warmStartGraph(env); + // Public pages must not enter TanStack Start: its first-request dynamic // import loads the entire React + Effect server graph and can take seconds // on a cold isolate. Classify and service-bind marketing at the Worker diff --git a/apps/cloud/src/start-virtual-entries.d.ts b/apps/cloud/src/start-virtual-entries.d.ts new file mode 100644 index 000000000..9fb43d820 --- /dev/null +++ b/apps/cloud/src/start-virtual-entries.d.ts @@ -0,0 +1,8 @@ +// TanStack Start's internal virtual server-entry modules (registered by the +// Start vite plugin; the same ids `start-server-core`'s `loadEntries` +// imports). server.ts imports them for the isolate warmup — only the +// module-evaluation side effect matters there, so the value shape is left +// untyped. Kept in a standalone declaration file: shorthand ambient modules +// only register from a non-module file (env-augment.d.ts is a module). +declare module "#tanstack-router-entry"; +declare module "#tanstack-start-entry";