Isolates ARE reused - those serving a page request had already served a median of 19 spans across 3 paths - they are just never warm for Start, because /mcp returns before fetchHandler. Two import-based warmups (#1679, #1681) moved nothing: loadEntries awaits three specifiers plus a separately cached manifest, so warming a subset leaves the request paying for the rest. Drive the real handler instead; it populates whatever those caches are and cannot drift with Start's internals.
Unified · Split
| @@ -95,6 +95,8 @@ declare global { · 94 unmodified lines | ||
| 95 | 95 | MCP_REQUEST_STATE_KEY?: string; |
| 96 | 96 | /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */ |
| 97 | 97 | MCP_2026_07_28_ENABLED?: string; |
| 98 | + // Kill switch for the Start server-graph warmup (server.ts). | |
| 99 | + START_GRAPH_WARM?: string; | |
| 98 | 100 | NODE_ENV?: string; |
| 99 | 101 | |
| 100 | 102 | // Shared with frontend |
| @@ -178,8 +178,70 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({ · 177 unmodified lines | ||
| 178 | 178 | traceRequest: traceCloudMcpRequest, |
| 179 | 179 | }); |
| 180 | 180 | |
| 181 | +// --------------------------------------------------------------------------- | |
| 182 | +// Start server-graph warmup | |
| 183 | +// --------------------------------------------------------------------------- | |
| 184 | +// | |
| 185 | +// Page latency was p50 19-49ms daily through 2026-08-16 and 2728ms the day | |
| 186 | +// after the 21:20 deploy. Not load: 2026-08-11 served MORE traffic (5.43M vs | |
| 187 | +// 4.38M requests) with pages at p50 41ms. | |
| 188 | +// | |
| 189 | +// What changed is where MCP work runs. The old hibernatable Agent bridge handed | |
| 190 | +// `/mcp` to the Durable Object almost immediately; the v2 stack authenticates | |
| 191 | +// and dispatches in the worker. `/mcp` returns above without touching | |
| 192 | +// `fetchHandler`, so an isolate can serve a lot of MCP traffic and still have | |
| 193 | +// never loaded the Start graph. Measured: isolates that served a page request | |
| 194 | +// had already served a median of 19 spans across 3 paths — they are reused, | |
| 195 | +// they are just cold for Start. The page request then pays `loadEntries`: p50 | |
| 196 | +// **3.1s**, against p50 33ms for the request's own work and 9-104ms warm. The | |
| 197 | +// same bundle in local workerd serves the same path in ~3ms. | |
| 198 | +// | |
| 199 | +// Warm by REPLAYING A REAL REQUEST through the handler, not by importing the | |
| 200 | +// virtual entry ids. Two attempts at the import approach (#1679, #1681) moved | |
| 201 | +// nothing in production: `loadEntries` awaits THREE specifiers | |
| 202 | +// (`#tanstack-router-entry`, `#tanstack-start-entry`, | |
| 203 | +// `#tanstack-start-plugin-adapters`) plus a separately-cached manifest, so | |
| 204 | +// warming a subset leaves the request paying for the rest. Driving the real | |
| 205 | +// handler populates whatever those caches are, by construction, and cannot | |
| 206 | +// drift when Start changes its internals. | |
| 207 | +// | |
| 208 | +// `/robots.txt` is the cheapest Start-served route. It runs once per isolate, | |
| 209 | +// under `waitUntil` so it outlives the request that triggered it, and costs one | |
| 210 | +// extra synthetic request per isolate in telemetry. | |
| 211 | +// `START_GRAPH_WARM=false` disables it without a deploy. | |
| 212 | +// --------------------------------------------------------------------------- | |
| 213 | + | |
| 214 | +let startGraphWarmupStarted = false; | |
| 215 | + | |
| 216 | +const warmStartGraph = (env: Env, ctx: ExecutionContext): void => { | |
| 217 | + if (startGraphWarmupStarted) return; | |
| 218 | + if (env.START_GRAPH_WARM === "false") return; | |
| 219 | + startGraphWarmupStarted = true; | |
| 220 | + | |
| 221 | + ctx.waitUntil( | |
| 222 | + (async () => { | |
| 223 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; a warmup failure must never affect the request that triggered it | |
| 224 | + try { | |
| 225 | + await fetchHandler( | |
| 226 | + new Request("https://executor.sh/robots.txt", { method: "GET" }), | |
| 227 | + env, | |
| 228 | + ctx, | |
| 229 | + ); | |
| 230 | + } catch { | |
| 231 | + // Advisory only — the request path still loads the graph lazily. | |
| 232 | + startGraphWarmupStarted = false; | |
| 233 | + } | |
| 234 | + })(), | |
| 235 | + ); | |
| 236 | +}; | |
| 237 | + | |
| 181 | 238 | const cloudflareHandler: ExportedHandler<Env> = { |
| 182 | 239 | fetch: async (request, env, ctx) => { |
| 240 | + // First fetch in this isolate drives one real request through Start in the | |
| 241 | + // background, including for /mcp — MCP traffic is what reaches these | |
| 242 | + // isolates first, and is why they are otherwise never warmed. | |
| 243 | + warmStartGraph(env, ctx); | |
| 244 | + | |
| 183 | 245 | // Public pages must not enter TanStack Start: its first-request dynamic |
| 184 | 246 | // import loads the entire React + Effect server graph and can take seconds |
| 185 | 247 | // on a cold isolate. Classify and service-bind marketing at the Worker |