Skip to content

Commit ca9617e

Browse files
authored
Warm the Start server graph on isolates that persist (#1679)
Timing the import apart from the request's own work in production: the loadEntries import is p50 3.1s, the request's work is p50 33ms, and a warm isolate answers in 9-104ms. The same bundle in local workerd serves the same path in ~3ms, so this is a cold-isolate cost on production metal, not slow code. Nearly every page request pays it: /mcp dispatches before fetchHandler, so MCP traffic creates and occupies isolates without ever loading the graph, and worker.dispatch ran 1,666 requests across 1,608 isolates. #1628 warmed on every isolate's first fetch and was reverted because it pulled the SSR graph into every isolate including single-shot MCP ones, taking memory kills from ~500-900 to 3.7k-9.4k per 5min. Wait for evidence the isolate persists instead: warm only after it has served a couple of fetches. START_GRAPH_WARM=false disables without a deploy.
1 parent 44d9ed3 commit ca9617e

3 files changed

Lines changed: 74 additions & 0 deletions

File tree

apps/cloud/src/env-augment.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ declare global {
9595
MCP_REQUEST_STATE_KEY?: string;
9696
/** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
9797
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 it ever costs memory again.
100+
START_GRAPH_WARM?: string;
101+
// How many fetches an isolate must serve before it is considered
102+
// persistent enough to warm. Defaults to 2.
103+
START_GRAPH_WARM_AFTER?: string;
98104
NODE_ENV?: string;
99105

100106
// Shared with frontend

apps/cloud/src/server.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,68 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({
178178
traceRequest: traceCloudMcpRequest,
179179
});
180180

181+
// ---------------------------------------------------------------------------
182+
// Start server-graph warmup
183+
// ---------------------------------------------------------------------------
184+
//
185+
// TanStack Start loads the router + start instance behind a dynamic import on
186+
// the first Start-handled request per isolate (`start-server-core`'s
187+
// `loadEntries`). Measured in production by timing that import apart from the
188+
// request's own work: the import is **p50 3.1s**, the work is **p50 33ms**,
189+
// and a warm isolate answers in **9-104ms**. The same bundle in local workerd
190+
// serves the same path in ~3ms, so this is a cold-isolate cost on production
191+
// metal, not slow code.
192+
//
193+
// Nearly every page request pays it. `/mcp` is dispatched above, before
194+
// `fetchHandler`, so MCP traffic — the large majority — creates and occupies
195+
// isolates without ever loading the graph. Page requests then land on those:
196+
// `worker.dispatch` ran 1,666 requests across 1,608 isolates (1.04 each).
197+
//
198+
// #1628 warmed on every isolate's FIRST fetch and was reverted: it pulled the
199+
// SSR graph into every isolate including MCP-only ones, and memory-limit kills
200+
// went from ~500-900 to 3.7k-9.4k per 5min. The fix for that is to stop
201+
// warming isolates that will not live long enough to use it. An isolate that
202+
// has already served several requests is one that persists — exactly the kind
203+
// a later page request can land on — so warming waits for that evidence.
204+
// Single-shot isolates, which is what the memory blowup was made of, are never
205+
// warmed.
206+
//
207+
// Deliberately NOT at module scope: a full warmup there trips workerd's
208+
// global-scope I/O restriction, and DO-only isolates should not carry the SSR
209+
// graph. `START_GRAPH_WARM=false` disables it without a deploy.
210+
// ---------------------------------------------------------------------------
211+
212+
const DEFAULT_WARM_AFTER_FETCHES = 2;
213+
214+
let isolateFetchCount = 0;
215+
let startGraphWarmupStarted = false;
216+
217+
const maybeWarmStartGraph = (env: Env): void => {
218+
if (startGraphWarmupStarted) return;
219+
if (env.START_GRAPH_WARM === "false") return;
220+
221+
isolateFetchCount += 1;
222+
const parsed = Number.parseInt(env.START_GRAPH_WARM_AFTER ?? "", 10);
223+
const warmAfter = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_WARM_AFTER_FETCHES;
224+
if (isolateFetchCount < warmAfter) return;
225+
226+
startGraphWarmupStarted = true;
227+
// oxlint-disable-next-line executor/no-promise-catch -- adapter boundary; fire-and-forget warmup outside any Effect runtime
228+
void Promise.all([import("#tanstack-router-entry"), import("#tanstack-start-entry")]).catch(
229+
() => {
230+
// Advisory only — the request path still loads the graph lazily.
231+
startGraphWarmupStarted = false;
232+
},
233+
);
234+
};
235+
181236
const cloudflareHandler: ExportedHandler<Env> = {
182237
fetch: async (request, env, ctx) => {
238+
// Every request counts toward this isolate's persistence, including /mcp
239+
// (which returns below without ever loading the graph) — MCP traffic is
240+
// precisely what keeps these isolates alive for a later page request.
241+
maybeWarmStartGraph(env);
242+
183243
// Public pages must not enter TanStack Start: its first-request dynamic
184244
// import loads the entire React + Effect server graph and can take seconds
185245
// on a cold isolate. Classify and service-bind marketing at the Worker
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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).
7+
declare module "#tanstack-router-entry";
8+
declare module "#tanstack-start-entry";

0 commit comments

Comments
 (0)