Cold start: the core barrel makes every Cloudflare Worker evaluate ~5.4 MB, two thirds of it unreachable from a page render #3143
Replies: 2 comments
|
A second measurement from the same site, on the round-trip side rather than the byte side, since it points at the same fix. Our production D1 runs an EmDash taxonomy lookup 13,611 times an hour: The reason that matters more than the per-query cost: our remaining latency is contention, not slow queries. In a 6-concurrent crawl of 3,230 pages, every one of the five slowest pages is fast when served alone. The worst, 7,001 ms under concurrency, is 0.63 s served serially. So the thing to reduce is round trips per render, which is the same target as lazily loading what a render does not need, and is why this belongs here rather than in its own thread. For context on what we already fixed on our side, so the numbers above are not mistaken for the whole picture: production went from 1,247,047,050 rows read in 24 hours and 458 s of D1 time to roughly 56 s per hour, and the topic-and-place page family from a mean of 1,945 ms with 64 pages over 5 s to a mean of 1,250 ms with none. That was our own indexing and an Two related things we have filed separately rather than pile in here: #3186, where a content list filtered by an explicit id list still gets an |
|
@eisenbruch thanks for your report and experiments here. Could you try the preview build in #3199 and see if it helps? |
Uh oh!
There was an error while loading. Please reload this page.
Summary
A stock
templates/starter-cloudflarebuild evaluates 134 modules, 5.41 MB at Worker startup, out of an 11.9 MB bundle. Roughly two thirds of that cannot be reached by a page render: the WordPress WXR importer, prosemirror/tiptap, the ATProto plugin registry,content-refresh,media-usage.The cause is that
packages/core's main entry is a flat barrel, and@emdash-cms/cloudflare's worker entry reaches it statically on two independent paths. Cloudflare's startup budget is spent evaluating what the entry statically reaches, not on the size of the upload, so this is paid on every cold isolate — including the overwhelming majority that only ever answer afetchand never receive a Cron Trigger.I have a fix deployed on a real site's staging environment, and the numbers for both it and a stock template. I also have a negative result that I think is the most useful thing here: the obvious two-line fix buys almost nothing on a stock template, and the reason why points at what the real fix has to be.
Method
wrangler check startup, which is the first of the three measurements #3071 keeps separate (module evaluation). I deliberately have not brought my own harness — #3071 is a maintainer's and it already measures exactly this metric, so it seemed better to report in its terms and let it be the baseline.Medians of 9 runs, local, macOS. The first run after a build is always high, so single samples are worthless here; I report medians and the spread.
Eager closure measured by walking static imports from the entry named in
dist/server/wrangler.json'smain. That figure, not the bundle size wrangler prints, is what tracks startup CPU.Measured:
templates/starter-cloudflareatorigin/main(2af0467)runScheduledTasksimported dynamicallyWhat is in that 5.41 MB, by library:
parse5is the WXR importer.@tiptap/coreand prosemirror arrive throughpackages/core/src/content/converters/portable-text-identity.ts:1. Also eagerly evaluated in that same build, below the top 16:@atcute/*,@emdash-cms/registry-client,registry-moderation,@noble/secp256k1, the@oslojs/*passkey stack,croner, and@wordpress/block-serialization-default-parser. (On our own siteprosemirror-view— a DOM library — is eager too; it is not in the stock template's set, so I am not claiming it as a template fact.)The negative result, which is the point
packages/cloudflare/src/worker.ts:16statically importsrunScheduledTasksfromemdash/middleware, and uses it only at line 100, inside the async IIFE inside the closurecreateScheduledHandlerreturns. Making itconst { runScheduledTasks } = await import("emdash/middleware")is two lines, behaviour-preserving, and typechecks.It moves 0.23 MB and about 5 ms. That is all. Because that is not the only eager path into the barrel:
runScheduledTasksfromemdash/middleware— deferrable.PluginBridge, which is not. Every Cloudflare template doesimport handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker"and re-exports it. It is defined inpackages/cloudflare/src/sandbox/bridge.ts, which imports from"emdash"directly. It is a Durable Object class, so it has to be a module-scope export — it cannot be lazily imported, by construction.So on a stock template, closing path 1 leaves path 2 holding the entire barrel open. No amount of deferring fixes this. The barrel itself is the fix.
I only saw the full win on our own site because its fetch entry is the adapter's own generated one and the cron handler is injected separately, so the barrel had exactly one eager path and closing it closed everything:
Cloudflare reported
Worker Startup Time: 52 mson the deploy. Same 518 modules uploaded before and after — nothing removed, nothing lost a capability, only when it is compiled. That is deployed on our staging environment, not yet on the public site, and has been serving real traffic including cron ticks (outcome: ok, 110 ms CPU on the tick that now pays the import, zero exceptions across the invocations I tailed).The second number in that table is what a template site could get if the barrel were addressed, and the first is what it gets today.
What I would propose, in order
1. Subpath exports plus lazy internal imports in
packages/core, keeping the barrel's public surface intact. Removing exports from the main entry is a breaking change for every consumer and I am not proposing it. The goal is that importingContentRepositorydoes not compileparse5.2.
PluginBridgeshould not reach the barrel. It needsContentRepository,PluginStorageRepository,normalizeCapabilities,ulidand a few types. Behind subpath exports that becomes a narrow import and the Durable Object export stops being a problem.3. Split
portable-text-identity.ts. Only three declarations need tiptap (PortableTextIdentityExtension,PortableTextSpanIdentity,PortableTextOpaqueBlock, collected at line 135). Both server-side converters import only the pure half — constants and plain functions, noExtension. Caveat I checked before proposing it: splitting the file alone is not sufficient, becauseconverters/index.ts:10re-exportsportableTextIdentityExtensionsandcontent/index.ts:5doesexport * from "./converters/index.js", so the barrel chain drags it back in regardless. This is really proposal 1 again, wearing a different hat — which is why I am raising it here rather than opening it as a standalone bug.4. A small one, separately:
fixtures/perf-sitecannot see any of this. Itssrc/worker.tsisexport default handlerfrom@astrojs/cloudflare/entrypoints/server, with noscheduledexport and no cron triggers — the only Cloudflare build in the repo that does not use@emdash-cms/cloudflare/worker. Every template anddemos/cloudflaredo. Pointing the fixture at the real worker entry would make #3071's startup number reflect the shape people actually deploy. Its startup measures 26.5–38.8 ms today versus the template's 65.7 ms, and that gap is exactly the thing being discussed.Prior art I read first
perf: add local render CPU benchmark) — measured against it, as above. Noting itsAccept-header change raised D1 snapshots by +67 queries as newly measured existing behaviour; nothing here touches that.perf(core): lazily load sandboxed plugins) — adjacent to proposal 2 and I would want to know whether it subsumes it before anyone writes code.perf(admin): defer icon and chart bundles) — read as the precedent for how a deferral is framed here.@emdash-cms/cloudflaresurface.Happy to open the two-line PR on its own as a small correctness item, but I did not want to present 5 ms as if it were the fix. The barrel is the fix, it is the larger change, and it seemed worth agreeing the shape before anyone writes it.
Measurements are reproducible from
origin/main: buildtemplates/starter-cloudflare, runwrangler check startupagainstdist/server/wrangler.json.AI disclosure: this investigation and writeup were done with Claude Opus 5 in Claude Code. The measurements are real and reproducible; I am responsible for them.
All reactions