EEltaCrew
HomeBlog

A 301 cannot be taken back: how 43 minutes of a wrong redirect came back as a 404 five days later, plus the Cloudflare edge-cache incident

Published 2026-09-16Measured 2026-09-04About 3 min readelta · EltaCrew

Moving two web services to subdomains, a broken 301 went out for 43 minutes. The server was fixed at once, but the browser was still following that 301 into a 404 five days later. What to do before emitting a permanent redirect, the only way to rescue one already sent, and the day a deploy "did nothing" because Cloudflare's edge kept the old CSS.

What happened

On 31 August 2026 I moved two web services with tens of thousands of generated pages from eltacrew.com/fixcode/ and /benefit/ to fixcode.eltacrew.com and benefit.eltacrew.com, with 301s on the old paths.

For the first 43 minutes that 301 did not strip the prefix and sent people to benefit.eltacrew.com/benefit/.... In Caddy, if uri strip_prefix and redir sit in the same block, the default directive order runs redir first. You have to wrap them in route {} to force the order.

@moved path /benefit /benefit/*
handle @moved {
  route {
    uri strip_prefix /benefit
    redir https://benefit.eltacrew.com{uri} permanent
  }
}

Fixed in 43 minutes, verified with curl. I thought it was over.

Five days later

On 4 September I clicked a link on my admin dashboard and got a 404. The browser had cached the 301 it received inside that 43-minute window and was going to the old destination without asking the server again. A 301 is "permanent", so the browser reuses it. Fixing the server tells the client nothing. My curl asked the server every time and stayed green; this is a class of bug you can never catch by looking at the server.

Crawlers cache it too. Do not treat it as one person's problem.

The only fix is to catch it at the destination

There is no way to clear a client's cache. The only remedy is to accept the wrong address on the receiving side. I added rescue redirects on both subdomains so that a request arriving as /fixcode/* or /benefit/* gets the prefix stripped once more, preserving the rest of the path.

The order from now on

The other incident: deployed, but nothing changed

A related incident from 10 August. I added an explanatory section to the Markdown converter and deployed it, and it did not appear. index.html was current, but style.css and app.js were coming from Cloudflare's edge as old files: cf-cache-status: HIT, Age: 6772, max-age=14400 (four hours). The old CSS still had body{overflow:hidden}, so the section existed in the HTML but scrolling was blocked and it never came into view.

Diagnosis in order:

  1. Hash of the actual file on the origin server
  2. Hash of the file at the live URL
  3. The cf-cache-status and Age headers

If the origin is current and live differs, it is the edge cache. The fix is to bump a ?v= query on CSS and JS every time they change. HTML has a short cache and refreshes first, leaving stale assets behind; that combination is the most confusing. A subpath site's favicon shows the old file for four hours for the same reason.

There was a misjudgment in there too. Two files whose hashes differed were about to be declared stale, but the difference was CRLF only; the content was identical. On Windows with core.autocrlf=true, git archive outputs CRLF. Compare after tr -d '\r'.

Summary

webdeploymentHTTPCloudflareCaddy

Related

One language per page: why I dropped IP-country detection and bilingual pages for browser language plus hreflangSix web services each picked their initial language differently. The one using IP country served English to Googlebot only, so the search-result language could not be controlled; the landing page mixed Korean and English on one page, against Google's "one language per page" expectation. How I unified every site under one rule.