A build step generated a hundred Cloudflare routing rules; the site needed two. Notes on deciding the Worker boundary deliberately, and why it is a security boundary as much as a cost one.
A build log line I stared at for longer than I should have:
fix-routes: 100 rules -> 2
That is a post-build step I wrote for one of my sites. The framework adapter had generated a hundred routing rules describing which requests should invoke the Worker. My step throws ninety-eight of them away and keeps two. The site works identically afterwards, serves faster, and costs less.
The interesting part is not the script. It is that the default was wrong in a way that nothing would ever have told me about, because a site that invokes a Worker on every request is not broken. It renders. It passes every test. It just quietly converts a static site into a compute bill.
Cloudflare Pages serves files from an edge cache. A request for a file that exists is answered by the network, near the user, without running any code of mine. There is no cold start because there is nothing to start, and there is no invocation because nothing was invoked.
A Worker is code that runs, per request, in response to a route match. It is genuinely fast and genuinely cheap, and it is not free, and more importantly it is not zero in the way a static asset is. Everything that flows through a Worker is something you are responsible for: its latency, its failure modes, its dependencies, and its count.
When you deploy a modern framework with an edge adapter, you get a hybrid. Some routes are prerendered to files, some are rendered on demand, and a routing manifest tells the platform which is which. That manifest is generated for you, and generated conservatively, because the adapter cannot prove your page is static. It can only observe that you did not explicitly say so.
Conservative here means: when in doubt, invoke the Worker. Which is the correct default for a framework author and the wrong outcome for most sites.
My case was ordinary. A content site: a few hundred articles built from markdown at build time, a handful of index pages, some static tools. Exactly one route needed to run code per request.
The generated manifest did not reflect that ratio at all. It listed rules for asset directories, for framework internals, for route patterns that existed only as build artifacts. None of it was wrong, exactly. It was just describing the framework's generality rather than my site's shape.
The consequence is not a crash. It is a set of small, permanent taxes:
And the thing that makes it insidious: none of it is visible in development. Locally, everything runs through the dev server anyway. The distinction only exists in production, where you are not watching it.
The fix is not clever. It is writing down which routes need compute and refusing to let a build step decide it for you.
The question I ask per route is: does the response depend on this specific request? Not on data — data can be baked in at build time. On the request. Its cookies, its headers, its geography, its body, who is authenticated.
For most content sites the honest answer, for almost every route, is no. An article page depends on the article, which was known at build time. A listing page depends on the set of articles, also known at build time. A sitemap depends on the same. None of those need to run per request; they need to be rebuilt when the content changes, which is a completely different operation and one that happens on a deploy rather than on a visit.
That reframing is the whole thing. Teams reach for server rendering because content changes, and then pay for computing on every read to serve content that changes on write. Build-time generation moves the work to the write, which is where it belonged, and where it happens orders of magnitude less often.
Being disciplined about this is not the same as being dogmatic. Some things must run per request, and pretending otherwise produces worse bugs than the cost it saves.
Anything that depends on who is asking. A personalised view, an authenticated area, a response that differs by session. Caching these at the edge is how you leak one user's data to another, which is a far more expensive mistake than an invocation.
Anything that touches a secret. An API token cannot be in a static bundle. Any call that needs one happens server-side, and on this platform that means a Worker. This is also the honest answer to "can I just call the API from the browser" — you can, and then your key is public.
Anything that accepts input. Form handlers, webhook receivers, anything with a request body to validate. These need somewhere trusted to run, and they need rate limiting, which is itself a reason to have a single controlled entry point.
Anything that must be correct at the instant of the request rather than at the instant of the build. Genuine real-time state. This category is much smaller than teams assume, and it is worth challenging every claimed member of it, because "the data might change" is usually satisfied by rebuilding on change.
The framing I have come to prefer is that the Worker boundary is an attack surface boundary.
A static asset has no logic to exploit. It cannot be injected into, it cannot be made to leak a secret it does not hold, and it cannot be induced to make a request on an attacker's behalf. It is a file. The entire class of server-side vulnerabilities does not apply to it.
Every route you move into a Worker is a route where code runs on input you do not control. That is not an argument against Workers; it is an argument for knowing exactly which routes they are. Two routes I can enumerate, reason about, rate limit and review is a security posture. A hundred routes generated by a build step is not a posture, it is a default.
This is the same principle I apply to data access. The reason a per-user query pattern is cheap is the same reason it is safe: there is no bulk surface. The reason a mostly-static site is cheap is the same reason it is hard to attack: there is barely any code path to reach. Cost and exposure are usually the same variable measured in different units, and optimising one tends to fix the other. I have stopped treating that as a happy coincidence and started treating it as the design rule.
My first attempt at this was to hand-maintain the routing configuration. I wrote the rules I wanted, committed them, and moved on.
They were overwritten on the next build. Of course they were: the adapter generates that file, and generated files do not respect your edits. I lost the correct configuration and did not notice for a while, because — as established — a site that invokes the Worker too often looks completely fine.
That is why the fix ended up as a post-build step rather than as a checked-in file. It runs after generation, it rewrites the manifest to the rules I actually want, and it prints what it did:
fix-routes: 100 rules -> 2. Only /insights* invokes the Worker;
every other page is served as a static asset.
The printed line matters more than it looks. A silent correction is a correction that will be
silently lost — someone changes the adapter version, the manifest shape changes, the script no longer
matches anything, and it keeps exiting zero while doing nothing. Printing the before and after count
means the regression appears in every build log, and a build that suddenly says 2 rules ->
2 when it used to say 100 -> 2 is telling you the upstream shape changed.
The general lesson I took from it: when you override a generated artifact, make the override loud. The failure mode of a quiet override is not an error, it is a reversion to a default that looks acceptable, and those are the ones that survive for months.
Deploy something, then look at what is actually invoking your code. Not what you assume. The generated routing manifest is a real file in your build output and you can read it. Most people never do, because nothing prompts them to.
Then, for each rule, ask whether the response depends on the request. Delete the ones where it does not. Automate that deletion, print what it did, and check the printed line still says what you expect after any dependency upgrade.
The result is not a micro-optimisation. On the site above it is the difference between a hundred routes of compute and two, and the two remaining ones are small enough that I can hold what they do in my head. That is worth more than the money it saves, because the routes I can reason about are the only ones I can actually secure.
Written by Liana Grigory, Entrepreneur and Software Engineer, from work on The Care Royal, Tegula Stone and Unified Savers. Everything above describes decisions actually made on those systems, including the ones that turned out to be wrong.