Managed IT · October 6, 2026 · intSignal Team

Your Deploy Shipped. Your CDN Is Still Serving the Old One.

Share this article

We pushed a fix, waited for the build, loaded the page, and the old behaviour was still there. So we assumed the fix was wrong and went back to the code.

It was not wrong. It was not even the fix's fault. The deploy had succeeded and the new JavaScript was sitting on the CDN — but the HTML that references that JavaScript was being served from an edge cache, and the copy we kept getting was thirteen minutes old.

One request header would have told us immediately:

$ curl -sI https://example.com/pricing | grep -iE "age|cache"
Age: 786
Cache-Control: public, max-age=0, must-revalidate
x-vercel-cache: HIT

Age: 786 means this response has been sitting in a cache for 786 seconds. x-vercel-cache: HIT means it never reached the origin. We were debugging a page that had been generated before our commit existed.

Why this is so easy to miss

Static sites hash their asset filenames — /_next/static/chunks/0cz1d0mv5g_q7.js — so an asset can be cached forever and a new build simply references a new name. That is the point of the design, and it works.

The weak link is the HTML, because the HTML is what maps "this page" to "those hashed assets". If the HTML is stale, it points at the old hashed assets, which are still happily served because they were cached forever. Every individual piece behaves correctly and the page is a coherent snapshot of the previous deploy.

So:

  • the deploy dashboard says success
  • the new asset exists at its new URL and you can fetch it directly
  • the page still runs the old code, correctly, with no errors

It feels exactly like a fix that did not work.

Check the right thing

The reliable test is not "is the page updated" but "which assets is the page actually asking for, and do they contain my change". Fetch the HTML, pull the asset references out of it, and grep those:

curl -s -H 'Cache-Control: no-cache' "https://example.com/pricing?cb=$RANDOM" \
  | grep -o '/_next/static/chunks/[a-zA-Z0-9_-]*\.js' | sort -u \
  | while read c; do
      curl -s "https://example.com$c" | grep -q "MY_NEW_MARKER" && echo "live in $c"
    done

Two details earn their place. The ?cb=$RANDOM query string usually produces a different cache key, which is the most dependable way to force a fresh copy from an edge you do not control. And Cache-Control: no-cache asks politely, which some edges honour and some ignore — so send both.

Pick a marker that only your change could produce. This bit us: we grepped the bundle for onload= to confirm a Turnstile fix had shipped, got a hit, and declared victory. onload= appears in plenty of unrelated code. The real marker was render=explicit&onload=, and it was not there. A loose grep gave us a false positive that sent us back to the code for a second round of debugging a fix that had never deployed.

Confirm the deploy separately from the page

Treat "did the build succeed" and "is the new HTML being served" as two different questions, and answer the first one from the deployment system rather than by looking at the site.

If the host reports deployments to GitHub, the status is one API call away:

id=$(gh api "repos/OWNER/REPO/deployments?per_page=1" --jq '.[0].id')
gh api "repos/OWNER/REPO/deployments/$id/statuses" --jq '.[0] | {state, description}'

That is how we eventually found that one of our pushes had not merely been slow — the build had failed, on a transient font-fetch error, while the previous deploy carried on serving. The site was fine. The fix was not on it. Nothing on the page could have told us that.

The habits worth keeping

  1. Before debugging a fix that "did not work", prove it is deployed. Build status from the deployment API, then the asset check above. It takes thirty seconds and it is the difference between fixing code and re-fixing code that was already correct.
  2. Use a marker unique to the change. A string that could plausibly exist elsewhere will give you a false positive at the worst moment.
  3. Read Age and the cache-status header. Any non-zero Age means you are looking at the past. Most CDNs expose a hit/miss header; learn the one yours uses.
  4. Remember that a browser is not a clean room either. A hard reload bypasses your cache, not the edge's. The ?cb= trick works in a browser too.
  5. Expect the edge to lag the origin. Purge-on-deploy is not instant everywhere, and some platforms hold stale HTML deliberately while revalidating. That is a feature; it is only a problem when you forget it exists.

The wider point

Caching layers are designed to be invisible, and they succeed — right up to the moment you are trying to answer a question they are quietly changing the answer to. When production disagrees with what you just shipped, the first question is not "what is wrong with my change". It is "am I looking at my change at all?"

Share this article