Skip to content
victor·stein
0%
booting ~/
./blog / infra / migration-status-board
cloudflare-workersd1monitoring

I'd become a single point of failure

VS Victor Stein · Jun 22, 2026 · 5 min read

It was a Saturday, and I was running curl against the same set of URLs I’d run it against the day before, pasting the results into a Google Sheet so that on Monday everyone would know where the migration stood.

I lead the team moving a large set of pages from one set of URLs to another — across eleven markets, each with its own domain and localized paths. The actual cut-over isn’t ours to flip: another team owns the redirect rules at the edge, so a market goes live when they change a rule, on their schedule. Our job was to verify each piece actually landed — is this market’s page being served by the new app yet, or still the old origin, or still redirecting to the old URL, or 404ing?

The only way to know was to look. So I looked — by hand, with curl, into a spreadsheet I kept alive myself.

The smell I tell everyone else to avoid

I carry one rule into every project: never create a single point of failure. And there I was, the single point of failure for an eleven-market migration. If I took a day off, the sheet went stale. If I got pulled onto something else, visibility died. The answer to “is the migration progressing?” lived entirely in my terminal history and my willingness to babysit a spreadsheet on a weekend.

That’s not a problem you fix by communicating harder. It’s a systems problem, and the fix is the one I’d reach for with any service that routes through a single box: take the human out of the path. So I spent the weekend building the thing that would replace me.

What it actually checks

The board is a grid: markets down the side, page types across the top. Each cell answers one question: who’s serving this URL right now? The new app, the old origin, a redirect back to the old URL, a 404, or an outright error. And the part the spreadsheet could never do: when did it last change?

Underneath, it’s the migration matrix made concrete — 11 markets × 5 page types × 4 host variants (QA and prod, each on the canonical domain and on .com), which lands at 200 cells per sweep once you drop the markets that share a domain. For each cell it builds the right localized URL — a different domain and a translated path per market — and goes and looks.

Why it follows redirects by hand

The naive version of this is fetch(url), read the final status, done. That version is wrong in a way that would quietly lie to the whole team.

The migration is redirects. A URL might 301 to its old location, or get silently rewritten to a different matched path, and a final 200 tells you nothing about which backend actually answered. So the checker follows redirects manuallyfetch(url, { redirect: 'manual' }) in a bounded loop, up to five hops — and classifies on the whole chain, not just the last response. A 3xx whose Location lands on the old route is a redirect, even if it eventually 200s. A final 200 with a Vercel server header is the new app; nginx is the old one. The classifier is a pure function with no I/O, unit-tested against captured real responses — the new app, the old origin, the trailing-slash 302, the canonical 301, the redirect, a CDN synthetic, the 404 — so I trust its verdicts enough to put them in front of another team.

Engineered to cost nothing

I built the whole thing on Cloudflare’s free tier, which became the most fun constraint of the project. A Worker on the free plan gets 50 subrequests and 10 ms of CPU per invocation — and every redirect hop is a subrequest. Two hundred cells, each potentially chaining five hops, does not fit in one invocation.

So it’s sliced. A cron fires every few minutes and processes one slice of about ten cells — sized so even the worst-case hop count stays under fifty subrequests — then advances a cursor; roughly twenty slices later the whole matrix has refreshed, about hourly. Within a slice the fetches run concurrently, so one hung host can’t drag the rest. The 10 ms CPU budget (which, mercifully, excludes time spent waiting on the network) is why every database write in a slice is batched into a single D1 batch(), and why the classifier stays allocation-light.

Storage is two tables doing two jobs. checks is append-only history — every result ever, which is what makes “flipped 3h ago” possible at all. current is a denormalized read model, one row per cell, that stores since_tswhen the backend last changed — as a plain column. So the dashboard’s main query reads ≤200 rows instead of scanning all of history, which keeps it under D1’s read cap, and the “when did this flip?” question — the one the spreadsheet could never answer — is a single cheap lookup.

The point was never the dashboard

Here’s what a weekend actually bought me. It isn’t that the checks are automated, though they are. It’s that I’m no longer in the path. The migration’s status is ground truth that updates itself, that anyone on my team — or the partner team I’m about to hand it to — can read at any moment without going through me.

And it quietly changed the conversation. When the data is live and self-updating, “the spreadsheet is stale” stops being something anyone can say. It becomes “this page is still on the old origin — here’s the cell, here’s when it last changed.” Accountability moves to where the work is: everyone can see what’s flipped and what hasn’t, including the changes they own and haven’t shipped yet.

It went to my engineers first; they’re already using it to see where things stand. The platform team is next. The migration is still in flight — but it no longer flows through me, and that was the whole point. If I’m out for a day, it keeps moving. That’s the only kind of system I trust.

cloudflare-workersd1monitoringmigrationtypescript ← all posts
NEXT UP →
I kept dropping tasks as a tech lead, so I built a TUI