Putting a Worker on a diet.
The hot-path Worker went from 908KB to 222KB in a day: drop the Sentry SDK, get zod out of the barrel export, move platform assets to R2. On Workers, bundle size is a tax you pay at every cold start.
The Worker that serves every LayerKick page weighed 908KB. Nobody decided that. It just accreted over time, the way these things do: an error-tracking SDK added here, a validation library there, two client scripts inlined as strings, a component registry baked in at build time. Each addition was reasonable on its own, and together they were a problem.
Last week I put it on a diet. The Sentry-and-zod cut alone took the backend worker from 908KB to 222KB, and by the time the branch merged the bundle sat at 202KB, 78% smaller, with four other workers getting the same treatment. Here’s why the weight mattered and where it went.
Size is latency on Workers
To explain why 908KB is a real cost and not just an aesthetic one, you need one fact about how Workers run. There’s no long-lived server process on Cloudflare Workers. Your code runs in V8 isolates, lightweight sandboxes that spin up on demand, one per colo (data center), and get evicted when they’ve been idle for a while.
The isolate itself starts in about a millisecond, which is genuinely fast. But loading and compiling 908KB of JavaScript on top of that is not. That parse-and-compile cost, the “cold start,” gets paid by the first shopper to hit each colo after every deploy. We deploy a lot, across a lot of colos, so “the first shopper” is not a rare edge case; it’s a steady trickle of real people eating the tax.
Our whole pitch is a warm page in tens of milliseconds. A hot path that occasionally spends its
entire time budget compiling an error-tracking SDK isn’t that. And cold-start waste showed up on
this same branch in other forms too: moving some setup work into a waitUntil (a
Cloudflare primitive that lets work continue after the response is sent) instead of doing it on
the request’s critical path saved a few hundred ms on the first page request per isolate. Cold
starts aren’t hypothetical. They show up in the beacons, the timing pings we collect from real
page loads.
The autopsy
esbuild’s metafile, a JSON report of exactly what ended up in the bundle and how big each piece is, made the diagnosis quick. It turned up four squatters:
| What | How it got in | The fix |
|---|---|---|
@sentry/cloudflare | ”We should have error tracking” | Structured logs + Cloudflare OTEL export |
| zod (runtime) | Barrel re-export of schemas | export type only |
| debug-bar + morphdom | Inlined as strings in the bundle | Served as static assets |
| Transpiled component registry | Baked in at build time | Loaded separately |
The Sentry number deserves its own line. On the shim, the tiny relay worker that fronts every request, the SDK was roughly 80% of the entire bundle. A worker whose whole job is to forward requests was mostly made of telemetry, which is a good sign you’ve stopped noticing what’s in your dependencies.
Four cuts
Sentry. To be clear, I love Sentry, and we still use it everywhere it earns its weight. All
the client-side code reports to it directly, and so does the dashboard app, for the breadcrumbs,
user context, and React integration, none of which is on the shopper’s request path. What I
didn’t want was the SDK riding inside the hot-path workers, the ones that spin up and down
constantly, where every kilobyte is a cold-start tax. So those workers don’t carry it. They emit
structured events through a shared observability module in one shared config package (reportError
and reportMessage over console.error and console.warn), which Cloudflare’s native OTEL export
(OpenTelemetry, the standard for shipping traces and logs) picks up automatically. That signal is
then forwarded to a separate worker that does have Sentry installed, and that worker pushes the
errors up. So the backend gets the full Sentry experience too, the same issues in the same place,
without a single frequently-cycling worker paying for the SDK. That took the SDK out of five
workers.
zod. The shared config barrel (the single index file that re-exports everything in the package)
re-exported runtime schemas, so importing a single type from it pulled the entire validation
library into every consumer. That’s the classic barrel-export trap: you asked for one thing and
got the whole crate. The barrel now uses export type for the zod-derived types, which is a
type-only export that disappears at build time. The schemas still exist for the code that actually
validates input; the workers that only need the shapes pay nothing for them.
Platform assets. The debug bar and morphdom were inlined in the bundle as strings, shipped in the worker whether or not any given request used them. They’re served as static assets now, out of the code bundle entirely, so they load only when a request actually needs them.
The registry. The transpiled component registry used to be compiled straight into the Worker. It’s loaded separately now instead of baked into the bundle. So there’s no transpiled theme code in the bundle at all, which also happens to unblock a cleaner deploy story down the line.
The pattern in all four is the same: the bundle should contain code that runs on the request path. Everything else is data, and data belongs in KV or R2, fetched when it’s needed and cached hard.
What it bought
A 202KB worker parses fast and builds fast, and it’s worth separating the two, because they help different people. Parsing fast is the shopper’s win: a smaller bundle is a smaller cold start, the parse-and-compile from earlier, which is the whole point of the diet. Building fast is mine. The full worker build is now 5 seconds, and to be clear that’s a developer-side step, not anything a shopper ever touches. Nobody hitting a client’s storefront is ever waiting on a build. It happens once, ahead of time, when we compile and deploy, and it’s a separate thing from the cold start entirely.
What a five-second build buys is freedom. It let me delete the --skip-build flag from the deploy
pipeline entirely, since there’s no build worth skipping anymore and one fewer flag is one fewer
way for a deploy to go sideways. And a build that cheap means we can push a configuration change
and rebuild on the fly, inside workflows and tests, whenever we need to, without the rebuild ever
becoming the bottleneck.
The workers still carrying the SDK on purpose got tree-shaking defines (__SENTRY_DEBUG__,
__SENTRY_TRACING__), build-time constants that let the bundler statically drop code paths those
workers never hit, so at least they shed the parts they don’t use. Tree-shaking is the bundler
eliminating code it can prove is unreachable; feeding it those flags makes more of Sentry
provably unreachable.
The same branch carried a security-hardening pass and the transpiler decoupling, but those are their own stories. Next in the log: what the client does with all this headroom, starting with making the shopper’s next click cheap. That prefetch work is in flight now.
LayerKick layers onto your existing Shopify theme and serves it from Cloudflare's edge. If anything goes wrong, traffic passes through to Shopify like we were never there. The fastest way to understand it is to watch it run on your own storefront, and the waitlist is the way in.