LayerKick LayerKick Journal
How It Works Pricing Journal 63ms TTFB Join Waitlist →
Waitlist →
Engineering / Entry №07

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.

Craig Ruks, Founder · April 7, 2026 · 5 min read · AI-drafted, founder-edited
908KB → 222KB
Backend worker bundle
202KB (-78%)
After the full branch
~80%
Sentry share of shim bundle
5s
Worker build time

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.

Bundle sizes measured from the esbuild metafile at build time, same tooling before and after.

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:

WhatHow it got inThe fix
@sentry/cloudflare”We should have error tracking”Structured logs + Cloudflare OTEL export
zod (runtime)Barrel re-export of schemasexport type only
debug-bar + morphdomInlined as strings in the bundleServed as static assets
Transpiled component registryBaked in at build timeLoaded 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.

If you're curious
See it on your own store.

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.

Join the waitlist → $200/mo beta · billing starts two weeks after signup
← Previous entry
The shim.
Next entry →
Predicting customer behavior.