Off Pages, onto Workers

This site ran on Cloudflare Pages its whole life: a static index.html plus a _worker.js in Pages advanced mode. On 2026-06-30 it moved to Cloudflare Workers with static assets. Same holding/ folder, same worker code, byte-for-byte identical responses (I diffed them before flipping the domain). What changed is everything around the bytes: how it deploys, what it can bind, and where its configuration lives.

Why it moved

Not for a feature. For an outage. A git-connected Pages build deployed the static files but silently dropped the Function, so every static path answered 200 and every worker route answered 404. From the outside the site looked up; underneath, half of it was gone. That failure is structural to how the Pages build treated this repo, so rather than paper over it, I moved to Workers, where a deploy is atomic: the worker and its assets ship together or not at all. There is no window where the shell is live and the brain is missing.

The real unlock: configuration as code

On Pages, the project's wiring lived in the dashboard. The KV namespace, the R2 bucket, the D1 database, the Durable Object, the environment variables: all clicked in through a web UI, invisible to git, undocumented outside my own memory, impossible to diff or review. Rebuilding the project meant remembering every toggle I had ever flipped.

On Workers, all of it is one checked-in file, wrangler.jsonc:

{
  "name": "aadhar-sh",
  "main": "holding/_worker.js/index.js",
  "assets": {
    "directory": "holding",
    "binding": "ASSETS",
    "run_worker_first": ["/*", "!/nav.js", "!/garage/*", "!/images/*.avif", ...]
  },
  "kv_namespaces":  [{ "binding": "RN_KV",   "id": "..." }],
  "r2_buckets":     [{ "binding": "PHOTOS_R2", "bucket_name": "aadhar-photos" }],
  "d1_databases":   [{ "binding": "RESTORE_DB", "database_id": "..." }],
  "durable_objects": { "bindings": [{ "name": "COUNTER", "class_name": "Counter" }] },
  "migrations":      [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }]
}

The binding list is now a code review, not a screenshot. A clean checkout plus one wrangler deploy reconstructs the entire project. There is exactly one honest exception: secrets. The signing key, the render token, and the cache-bust secret stay out of git on purpose, set once with wrangler secret put. A checkout is reproducible; it is deliberately not turnkey without those three.

What it opened up

Given the constraint I care about most here (this site is frameworkless and buildless, and staying that way), the interesting question was never "which framework." It was: what can the platform do once the config is code? Four things I have already used, and a few more now in reach.

1 · Granular routing (already shipped)

That run_worker_first array is a denylist. The worker sees every request except the paths I negate with !, which serve straight from the edge and never wake it. I just cut the avif thumbnails loose: they are the primary, highest-volume image format (every modern browser takes the avif <source>), so serving them edge-direct drops a worker invocation off the busiest request on the site.

I proved it with wrangler tail: fetched one avif and one jpg with unique query strings and watched the live log. The avif logged zero invocations; the jpg logged one. The jpg deliberately stays worker-first, because /images/*.jpg would deep-match the R2 originals at /images/full/*.jpg and starve that route. avif has no such twin, so it is a clean cut. You can watch the same split live in the prober below.

2 · Durable Objects, in-house (already shipped)

Pages cannot host a Durable Object at all. The homepage visit counter (the little odometer at the bottom of the front page) had to reach across to a separate Worker, cf-garage, and borrow its Counter class by cross-script binding. On Workers the class lives right here, in holding/_worker.js/counter.js, exported from the entry, with its migration in the same config. To keep the number honest across the move, the new object self-seeds once from the count it had on the old one (about 2,375) instead of resetting to zero. Same odometer, now wired into its own dashboard.

export class Counter { async fetch(req){ /* atomic ++ in state.storage */ } }

3 · Atomic deploys and versions (relying on it)

A Worker deploy is all-or-nothing, and that is precisely what makes the avif cut safe. The old Pages hazard was a deploy race: a real thumbnail could 404 for a blink during a deploy, and Cloudflare's edge would then cache that 404 for hours. Atomic deploys remove the blink, so there is no transient miss of a file that actually exists. Workers also supports versioned, gradual rollouts (ship a version to 10% of traffic first), which Pages never offered for production.

4 · Observability and tail (already using it)

Structured logs are one line in the config ("observability": { "enabled": true }), and wrangler tail streams the live worker to my terminal. That is how I verified the routing change against production without guessing, and how I confirmed the counter migration ticked correctly. On Pages this was second-class; on Workers it is the default.

Still on the table

Things the move makes reachable that I have not built yet, kept honest because none of them ship today:

See the routing split yourself

This fetches three live paths and prints their real response headers. The two static paths come back cacheable (a long max-age, immutable), which is exactly why they can serve from the edge without the worker. The homepage comes back no-cache: the worker renders it on every request (it re-randomizes the photo grid and reads the visit counter), so it can never be an edge-cached file. Nothing here is mocked; it is your browser hitting production.

The honest scorecard

 Pages (before)Workers + assets (now)
deploygit build, could drop the Functionatomic, all-or-nothing
configdashboard clicks, off gitone wrangler.jsonc, in git
routingworker saw everythingper-path denylist, static skips it
Durable Objectsnot possible, cross-script onlyin-house class + migration
cronnonescheduled handlers available
bytes servedidenticalidentical
secretsdashboardout of git, by hand (correct)
The old Pages project still exists, parked as a rollback.