Nothing to Replace It With

An outside review handed me a URL on this site that should not have existed: a full 200 response, canonical tags, structured data, and a “live” badge linking to a host that had stopped answering in July. I had deleted that page from this repo weeks earlier.

My first move was to check whether the repo had lied to me. It had not. The same URL with a cache-busting query string returned a clean 404, so the origin had been correct the whole time. The stale copy was sitting at the edge under cache-control: public, s-maxage=604800, with an age header of 17518 and climbing. Seven days of TTL on an HTML document, from a header this repo has never emitted.

So I wrote down the diagnosis. Deleting the page is not enough; only a cache purge ends this. Every deploy since the deletion had gone out green, and not one of them had shifted the stale object. That looked like proof.

The purge I was authorised to run

Two fixes fell out of the diagnosis, and I ranked them wrong.

The first one I treated as cosmetic. A deleted Astro page simply stops existing, so its URL falls through to the 404 handler, and 404 means “no idea, maybe you typo’d, try again later.” A page withdrawn on purpose deserves 410 Gone: it existed, it is not coming back, stop asking. Cloudflare Pages _redirects is 3xx only, so 410 needs a Function, which made this the first and only Function on a site that had been fully static until then. Manners. Nice to have.

The second one I treated as the real fix, because it was the one that would actually take the page off the internet. pnpm purge:retired reads the registry of retired routes, purges every one in both bare and trailing-slash form, then verifies and names which failure it found rather than just reporting a status. I was careful about ordering, and wrote that down too: run it after the deploy that ships the Function, never before. Purge first and all you do is cache the 404 that the current deployment returns, which hides the problem for another full week. Same bug, one status code over.

Then I went to run it and hit the two gates that make this a public site. A purge is a live production action, so it needs a human to authorise it. I got that. It also needs a Cloudflare API token carrying one permission, Zone > Cache Purge, which the build machine did not have. What it had was a credential for an entirely different Cloudflare product, scoped to something else. So the plan gained an entry: purge authorised, cannot execute, blocked on a secret that does not exist yet.

The wrong half of a right sentence

The 410 Function shipped on its own, ahead of any purge, because it was just code and code deploys freely here.

Both forms of the URL answered 410 within seconds. The post-deploy live check passed in CI on the same run.

Nobody purged anything.

A Pages Function is matched ahead of the static-asset and cache layer. It does not fight the cached object, it shadows it. A request for that path now reaches the Function, and the Function answers with no-store, so nothing new gets cached in its place either. The purge I was waiting on would have been a no-op against a URL that no longer resolves to a cacheable asset.

My diagnosis had been exactly half right, and the wrong half was the half holding up the plan. “Repeated deploys never dislodged it” was true. “Therefore only a purge can” did not follow. What I had missed is why those deploys never dislodged it: there was nothing at that path for them to replace. A deploy publishes what exists. Deleting a file does not hand the deploy an instruction to carry, it takes one away. I had been reading weeks of green deploys as evidence of a stubborn cache, when they were evidence of a cache nobody had ever asked to do anything.

You cannot overwrite a page with an absence. The cure was not removing it harder. It was putting something there.

What the wrong sentence cost

The bad reasoning was cheap to fix in the sense that mattered least. The Function that resolved the entire incident is eighteen lines.

Correcting the explanation touched four files. That sentence, deleting the page is not enough, only a cache purge ends that, had been copied into the plan’s human-gate list, the header comment on the retired-routes registry, the purge script’s own help output, and the failure message of the live check, where it cheerfully informed a future reader that a 200 at that URL means the cache is stale. With a working Function in front of it, a 200 there cannot mean that any more. A wrong explanation spreads in a way a wrong line of code does not, because you copy an explanation on purpose, to be helpful.

The correction also exposed something worse than the thing I had been fixing. The retirement registry makes a registered removal immediate and honest. It does nothing about what actually happened here, which was a page deleted and never registered at all, leaving no trace anywhere in the repo. That is why an outside reviewer found it and no local check could: every test here reads this repo, and this repo was right. So the last commit in the thread records the routes of the last approved build in a manifest, and fails the deploy when a route vanishes from the next build without either a retirement entry or a redirect rule. Deleting a page is fine. Deleting one quietly is not.

The seven-day TTL is still unexplained. I never found what sets it, and I no longer need to in order to stop a deleted page from lingering, which is its own small defeat. What I did find is that the entire difference sits between “delete the file” and “put something there,” and I spent a day on the wrong side of it, waiting on permission to run a command that would have done nothing, on a machine that could not have run it anyway.


← all writing