Cloudflare Workers: Startup Cost
A Worker runs its modules' top-level code before it serves its first request, and a Durable Object host does the same each time Cloudflare starts an isolate for it. This page covers what that load costs, what the Helix SDK does at load, how to keep your own code cheap to load, and how to measure it.
What Cloudflare charges at startup
- A documented limit. Cloudflare documents a 1 s CPU limit for a Worker's startup (evaluating its global scope); a Worker that exceeds it fails to deploy with error
10021. Below the limit, startup time still delays the first request of every new isolate. - Evaluation and retained heap dominate. In a paired experiment an application team ran on deployed Workers, the startup cost tracked the code that ran at load (module top-level code, the constructors and setup calls it makes) and the heap it left behind; the cost of parsing code that never ran was small. Bundle size still matters where it comes with work at load: dropping duplicate copies of zod shrank the upload and the startup time together (table below), because each copy was also evaluated.
What the SDK does at load
Nothing schema-wise, and it loads only what its entry point needs. Importing a Helix package builds no zod schemas:
- Every schema the SDK exports is a getter:
messageSchema(),streamChunkSchema(),errorDetailSchema(). The first call builds the schema and every later call returns the same instance (core'slazySchema), so a Worker builds only the schemas it actually parses with, on first use. The oldMessageSchema-style constants are gone; see the lazy schemas upgrade guide. - Setup calls build nothing per call.
defineAgent,defineTool,createSubAgentTool,createFinishTool,new AgentRegistry()andcreateAgentServerbuild no schemas of their own. The two they cannot avoid are shared instances, built once:voidOutputSchema()(the output schema of the finish tool) andskillMetadataSchema()(skill metadata, validated when an agent withskillsis defined). - One copy of zod. zod is a peer dependency of
runtime-cloudflare,runtime-jsandstore-cloudflare, so a Worker bundles your zod and nothing else (zod peer dependency). aion the first model call.@helix-agents/llm-vercelimportsaithe first time an adapter calls the model, so importing it builds none ofai's schemas.- Rarely used code behind its own entry point. The test mocks are at
@helix-agents/core/testing/mocks, the SKILL.md parser (and its YAML dependency) at@helix-agents/core/skill-file, and the File Store workspace (and@cloudflare/shell) at@helix-agents/runtime-cloudflare/filestore, so the main entries load none of them (names moved to their own entry points).
The repository keeps it that way with three guards, run in CI:
- Startup guard (
packages/startup-guard): imports each Workers-facing entry point (@helix-agents/core,runtime-cloudflareand itsdo/do-clients/direct-access/filestoreentries,store-cloudflareand itsd1/streamentries,runtime-js,llm-vercel,ai-sdk/cloudflare,memory-cloudflare,embedding-cloudflare) in a fresh Node process, as ESM and as CommonJS, counts every zod schema the import builds and attributes each one to the package that built it. Any schema built by a Helix package fails the test. Schemas built by third-party packages are counted and printed, not failed on (none are built today). It also checks what the entries load: core's main entry loads no mock and no YAML parser,runtime-cloudflare/doloads none of the Workflows runtime, the main entry no@cloudflare/shell,llm-vercelnoai. - Dist check: fails when a Workers-facing package's
distinlines its own copy of zod or immer. - A real Durable Object host:
examples/research-assistant-cloudflare-dobundles itself exactly aswrangler deploywould (--dry-run, nothing is deployed), fails unless the bundle holds one copy of zod and builds no Helix schema at load, starts it in workerd and checks that it answers a request.
Keeping your own code lazy
The SDK building nothing at load only helps if your Worker does not build everything at load either. The Cloudflare DO example is built this way; its pattern:
1. A memoised getter for anything expensive. A few lines, no dependency (src/lazy.ts in the example):
export function lazy<T>(build: () => T): () => T {
let cell: { value: T } | undefined;
return () => (cell ??= { value: build() }).value;
}2. Schemas through lazySchema. Write your schemas the way the SDK writes its own, and take types from the getter:
import { z } from 'zod';
import { lazySchema } from '@helix-agents/core';
export const researchStateSchema = lazySchema(() =>
z.object({
topic: z.string().default(''),
notes: z.array(z.object({ content: z.string(), source: z.string().optional() })).default([]),
})
);
export type ResearchState = z.infer<ReturnType<typeof researchStateSchema>>;A schema that nests another calls the inner getter inside its own factory (z.array(noteSchema())), so using it builds only what it contains.
3. Agents and tools as getters, registered as factories. defineAgent and defineTool are cheap, but the schemas, tools and model an agent definition pulls in are not. Wrap the definition in lazy and register the getter with registerFactory, so an agent is built the first time a request resolves it:
import { defineAgent } from '@helix-agents/core';
import { AgentRegistry } from '@helix-agents/runtime-cloudflare/do';
export const researchAssistantAgent = lazy(() =>
defineAgent({
name: 'research-assistant',
stateSchema: researchStateSchema(),
outputSchema: researchOutputSchema(),
tools: [webSearchTool()],
systemPrompt: 'You are a research assistant.',
llmConfig: { model: makeModel('gpt-6-luna') },
})
);
const registry = new AgentRegistry();
registry.registerFactory('research-assistant', researchAssistantAgent);4. Providers on first use. Create SDK clients and model providers (createOpenAI, …) inside a getter too, not at module scope (src/openai-provider.ts in the example). This also lets them read env values that a Worker only has once a request arrives.
Import from /do
A Worker that hosts agents in Durable Objects can import the DO runtime from @helix-agents/runtime-cloudflare/do instead of the main entry:
import { createAgentServer, AgentRegistry } from '@helix-agents/runtime-cloudflare/do';
import { CloudflareFileStoreWorkspaceProvider } from '@helix-agents/runtime-cloudflare/filestore'; // if you use it/do exports createAgentServer, DurableObjectAgentBase, the DO stores and clients, AgentRegistry, createRemoteAgentWorkerHandler and serviceBindingTransport, and loads none of the Cloudflare Workflows runtime, which the main entry also carries. The main entry exports the same objects, so switching is optional and can be done one import at a time. The gain is largest where nothing tree-shakes (Node: about 59 ms against 68 ms for the main entry, table below); a wrangler bundle already drops most unused code, so expect a smaller saving there, and little for a Worker that still imports the main entry for another name. See the upgrade guide for what /do excludes and for the test changes a switch needs (vi.mock specifiers; keep the package name in vitest's server.deps.inline).
Measuring
wrangler check startupbundles the Worker and profiles its startup. It reports theActiveCPU time of the load and writes a CPU profile you can open in Chrome DevTools. Run it a few times and take the median: the numbers vary from run to run. Its exit status does not tell you the Worker works, so check separately that it starts and serves a request.- Inside this repository,
pnpm --filter @helix-agents/startup-guard run measure(afterpnpm run build) prints, for each Workers-facing entry point, the zod schemas built at import (Helix and third-party), the import time and the heap growth, as medians of 5 fresh Node processes.pnpm --filter @helix-agents/startup-guard run testis the enforced check, andpnpm --filter @helix-agents/example-research-assistant-cloudflare-do run test:startupruns the workerd check on the example. - Your own startup check must accept zero schemas. Importing the SDK builds no zod schemas now, so a Worker whose own schemas are lazy builds none at load. A check that counts schemas built at load (for example by wrapping zod's constructor) and treats a count of 0 as broken instrumentation now fails although nothing is wrong. Accept 0, and prove the instrumentation separately: after the count, call one getter (
messageSchema()) and check that the count goes up.
Numbers
Measured on 2026-10-21 on one machine (Node 24; 5 runs per variant, medians).
SDK entry points, import time / heap growth / Helix-built zod schemas at import. "Before" is the SDK's main before this work; "Lazy schemas" adds one copy of zod and lazy schemas; "Now" also loads less (ai on the first model call, /do, /filestore, /skill-file, mocks out of core's main entry):
| Entry point | Before | Lazy schemas | Now |
|---|---|---|---|
@helix-agents/core | 84 ms / 19.5 MB / 1386 | 33 ms / 5.6 MB / 0 | 27 ms / 4.6 MB / 0 |
@helix-agents/runtime-cloudflare | 173 ms / 34.8 MB / 2582 | 70 ms / 10.0 MB / 0 | 68 ms / 11.6 MB / 0 |
runtime-cloudflare/do | — | — | 59 ms / 9.9 MB / 0 |
runtime-cloudflare/do-clients | 99 ms / 23.1 MB / 1578 | 34 ms / 5.7 MB / 0 | 26 ms / 4.7 MB / 0 |
runtime-cloudflare/filestore | — | — | 31 ms / 5.3 MB / 0 |
@helix-agents/store-cloudflare | 118 ms / 24.9 MB / 1406 | 59 ms / 8.7 MB / 0 | 52 ms / 7.8 MB / 0 |
store-cloudflare/d1 | 93 ms / 22.0 MB / 1387 | 36 ms / 6.1 MB / 0 | 28 ms / 5.1 MB / 0 |
store-cloudflare/stream | 116 ms / 24.3 MB / 1405 | 60 ms / 8.2 MB / 0 | 49 ms / 7.2 MB / 0 |
@helix-agents/runtime-js | 92 ms / 21.1 MB / 1386 | 42 ms / 7.1 MB / 0 | 32 ms / 6.2 MB / 0 |
@helix-agents/llm-vercel (+ ai's) | 109 ms / 23.8 MB / 1425 (+169) | 58 ms / 9.8 MB / 0 (+169) | 26 ms / 4.7 MB / 0 (+0) |
ai-sdk/cloudflare | 102 ms / 23.9 MB / 1610 | 36 ms / 6.2 MB / 0 | 27 ms / 5.2 MB / 0 |
@helix-agents/memory-cloudflare | 86 ms / 19.6 MB / 1397 | 33 ms / 5.6 MB / 0 | 26 ms / 4.6 MB / 0 |
In Node, the main runtime-cloudflare entry's heap rose from 10.0 to 11.6 MB with code splitting: Node holds its shared chunks as several modules instead of one file. A Worker bundles everything into one script, where the host numbers below apply.
An application's Durable Object host (built on runtime-cloudflare, llm-vercel and ai), rebuilt against each SDK version; Active is the wrangler check startup median of 5 interleaved rounds, heap is Node's heap after GC, CPU is Node's single-thread CPU time for the load. The host imports runtime-cloudflare's main entry, not /do, so these numbers do not include what /do saves:
| SDK | Active | Heap | CPU | Upload | zod schemas at load |
|---|---|---|---|---|---|
| Last release (core 0.51, runtime-cloudflare 7.0.0) | 77.6 ms | 31.9 MB | 163 ms | 8015 KiB | 2355 (2 copies of zod) |
| SDK main before this work | 97.7 ms | 35.2 MB | 176 ms | 8410 KiB | 2702 (2 copies of zod) |
| One copy of zod + lazy schemas | 44.2 ms | 16.7 MB | 115 ms | 7223 KiB | 227, all third-party (1 copy) |
| + load less (now) | 36.6 ms | 13.8 MB | 102 ms | 7317 KiB | 0 (1 copy) |
All rows are one run; its raw Active samples: main 100.1, 84.3, 97.7, 103.6, 79.0 ms; lazy schemas 51.5, 42.9, 42.5, 44.2, 44.8 ms; now 40.6, 36.6, 36.4, 38.5, 36.0 ms. One copy of zod alone (before lazy schemas) measured about 72 ms Active in an earlier run.
The 227 schemas left after lazy schemas were built by ai and @ai-sdk/gateway when llm-vercel imported ai; loading ai on the first model call removes them. The upload grows by about 95 KiB against lazy schemas (the lazily loaded ai and the shared chunks) while the load cost still drops. The example DO host (examples/research-assistant-cloudflare-do) builds no schemas at load, Helix or its own, and measures about 23–24 ms Active.