History

Warm the Start server graph on isolate first fetch (#1681)

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.

Rhys Sullivan committed 2 months ago

Commit 29a0aea1 parent 332955f3 files · +62 −0Browse files

Unified · Split

3 files
  1. apps/cloud/src/env-augment.d.ts +3 −0
  2. apps/cloud/src/server.ts +51 −0
  3. apps/cloud/src/start-virtual-entries.d.ts +8 −0
apps/cloud/src/env-augment.d.ts+3−0
@@ -95,6 +95,9 @@ declare global { · 94 unmodified lines
95 MCP_REQUEST_STATE_KEY?: string;95 MCP_REQUEST_STATE_KEY?: string;
96 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */96 /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
97 MCP_2026_07_28_ENABLED?: string;97 MCP_2026_07_28_ENABLED?: string;
98 // Kill switch for the Start server-graph warmup (server.ts). Set to
99 // "false" to disable without a deploy if memory pressure returns.
100 START_GRAPH_WARM?: string;
98 NODE_ENV?: string;101 NODE_ENV?: string;
99 102
100 // Shared with frontend103 // Shared with frontend
apps/cloud/src/server.ts+51−0
@@ -178,8 +178,59 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({ · 177 unmodified lines
178 traceRequest: traceCloudMcpRequest,178 traceRequest: traceCloudMcpRequest,
179});179});
180 180
181// ---------------------------------------------------------------------------
182// Start server-graph warmup
183// ---------------------------------------------------------------------------
184//
185// Page latency was p50 19-49ms every day up to 2026-08-16 and 2728ms the day
186// after the 21:20 UTC deploy. It is not load: 2026-08-11 served MORE total
187// traffic (5.43M requests vs 4.38M) with pages at p50 41ms, so the
188// reconnect-storm/volume explanation does not hold.
189//
190// What changed is where MCP work runs. The old hibernatable Agent bridge
191// handed `/mcp` to the Durable Object almost immediately; the v2 stack
192// authenticates and dispatches in the WORKER (`mcp.auth.jwt_verify` alone runs
193// ~35k times per 6h there). That spins up far more worker isolates, and `/mcp`
194// returns above without ever touching `fetchHandler` — so those isolates never
195// load the Start graph. Page requests then land on them cold and pay
196// `loadEntries`: measured p50 **3.1s**, against p50 33ms for the request's own
197// work and 9-104ms for a warm isolate. The same bundle in local workerd serves
198// the same path in ~3ms, so this is a cold-isolate cost, not slow code.
199//
200// So warm on the isolate's FIRST fetch, in the background: MCP traffic then
201// pre-warms an isolate before a page request reaches it. This is #1628, which
202// worked; it was reverted (#1634) when memory-limit kills jumped, but that
203// landed during the 2026-08-17 storm — 11.67M requests in a day against a
204// 2.6-4.3M baseline. Volume is back to baseline and `exceededMemory` is
205// currently zero, so the condition that made it expensive is gone.
206// `START_GRAPH_WARM=false` disables it without a deploy.
207//
208// Deliberately NOT at module scope: a full warmup there trips workerd's
209// global-scope I/O restriction, and DO-only isolates should not carry the SSR
210// graph.
211// ---------------------------------------------------------------------------
212
213let startGraphWarmupStarted = false;
214
215const warmStartGraph = (env: Env): void => {
216 if (startGraphWarmupStarted) return;
217 if (env.START_GRAPH_WARM === "false") return;
218 startGraphWarmupStarted = true;
219 // oxlint-disable-next-line executor/no-promise-catch -- adapter boundary; fire-and-forget warmup outside any Effect runtime
220 void Promise.all([import("#tanstack-router-entry"), import("#tanstack-start-entry")]).catch(
221 () => {
222 // Advisory only — the request path still loads the graph lazily.
223 startGraphWarmupStarted = false;
224 },
225 );
226};
227
181const cloudflareHandler: ExportedHandler<Env> = {228const cloudflareHandler: ExportedHandler<Env> = {
182 fetch: async (request, env, ctx) => {229 fetch: async (request, env, ctx) => {
230 // First fetch in this isolate kicks the graph load off in the background,
231 // including for /mcp — MCP traffic is what reaches these isolates first.
232 warmStartGraph(env);
233
183 // Public pages must not enter TanStack Start: its first-request dynamic234 // Public pages must not enter TanStack Start: its first-request dynamic
184 // import loads the entire React + Effect server graph and can take seconds235 // import loads the entire React + Effect server graph and can take seconds
185 // on a cold isolate. Classify and service-bind marketing at the Worker236 // on a cold isolate. Classify and service-bind marketing at the Worker
apps/cloud/src/start-virtual-entries.d.ts+8−0
@@ -0,0 +1,8 @@
1// TanStack Start's internal virtual server-entry modules (registered by the
2// Start vite plugin; the same ids `start-server-core`'s `loadEntries`
3// imports). server.ts imports them for the isolate warmup — only the
4// module-evaluation side effect matters there, so the value shape is left
5// untyped. Kept in a standalone declaration file: shorthand ambient modules
6// only register from a non-module file (env-augment.d.ts is a module).
7declare module "#tanstack-router-entry";
8declare module "#tanstack-start-entry";