Real fixes from seven live sites. No pitch.Log in / My reports
Idea to Traffic Pressure Test

I deployed new JavaScript and the browser is still running the old one

Cloudflare Pages serves .js and .css with max-age=14400, four hours with no revalidation, so any browser that loaded the page earlier keeps the old file. Neither a _headers file nor a Cache-Control header set in middleware fixes it. Content-hash the asset URL in index.html instead.

A fix deployed that visitors never received

A deploy went out with a change to the front end script. The file on the server was correct. We fetched it, read it, and the new code was in there. The page still ran the old version, so the change was reported as broken and the deploy was treated as a silent failure. Working that out cost a full working cycle.

What makes this one convincing is that nothing appears to break. The page loads. Requests return 200. There is no console error and no failed network request. A browser that loaded the page earlier keeps the JavaScript it already holds, so everything that script drives behaves as it did before the deploy. From inside that browser the deploy looks like it never happened, and the natural conclusion is that the upload failed quietly.

The same trap turned up at page level rather than asset level. On one deploy the homepage updated immediately, while three other paths kept returning the old homepage HTML for hours: /robots.txt, a deliberately wrong URL that was being used as a probe, and a new key file that had just been added. All three had been requested before the deploy, so all three had a stored response with a four hour lifetime sitting in front of them, returning 200 with a body that had been correct several hours earlier.

The edge keeps .js and .css for four hours

Cloudflare Pages serves .js and .css with Cache-Control: max-age=14400. That is four hours with no revalidation attached. A browser holding a response under that header does not send a conditional request and does not ask whether anything changed. It reads its own copy and moves on. Anyone who loaded the page before the deploy keeps the old file until their four hours expire, so the window in which the site is correct for new visitors and wrong for returning ones is four hours wide from each visitor's last load.

Two fixes look obvious and neither works. They are worth naming so nobody spends a cycle on them.

The first is a _headers file. Pages honours _headers only when it serves an asset directly. If every request passes through a Pages Function middleware, the middleware is what answers, so _headers never applies. Extension globs of the /*.js form do not match there in any case, so the rule is inert twice over.

The second is setting Cache-Control in the middleware itself. That works for HTML and does not work for .js or .css. Cloudflare's edge caches those extensions and stamps its own max-age=14400 over whatever the Worker set. The header you wrote is replaced before the response reaches anyone.

The response headers show this plainly. /index.html comes back with cf-cache-status: DYNAMIC and honours the header the Worker set, because a dynamic response is not treated as a cacheable static asset. /app.v3.js comes back with cf-cache-status: MISS and max-age=14400 regardless of what the Worker asked for. MISS is not good news here. It means the path was considered cacheable, was fetched, and was stored on the edge's terms rather than yours.

Compare what the server sends with what you deployed

Read the headers on one HTML path and one script path and compare them:

curl -sI https://ideatotraffic.com/ | grep -i -E 'cache-control|cf-cache-status'
curl -sI https://ideatotraffic.com/app.v3.js | grep -i -E 'cache-control|cf-cache-status'

If the HTML path reports DYNAMIC with the header you set and the script path reports MISS with max-age=14400, the asset is being cached on terms you do not control, and any client that loaded it earlier is still running the earlier build.

Next, separate two claims that get treated as one. The first claim is that the server holds the new file. The second is that clients are running it. Check the first directly:

curl -s https://ideatotraffic.com/app.v3.js | grep -c 'string you just added'

A non-zero count on the server combined with old behaviour in a browser is not an ambiguous result. It is this failure, and no amount of redeploying will change it.

For the page level version, compare bodies rather than statuses. Hash the homepage and hash the path you believe is stale, and if a path that should be different is byte identical to a homepage from hours ago, you are reading a cached response:

curl -s https://ideatotraffic.com/ | md5sum
curl -s https://ideatotraffic.com/robots.txt | md5sum

Put a content hash in every asset name

Stop trying to win the argument about the header. Change the URL instead, so there is nothing cached to serve.

  1. Add a build step that computes the md5 of the asset and takes the first 8 characters of it.
  2. Have that step rewrite the reference in index.html to app.v3.js?v=<first 8 characters of the md5> before each deploy, so the stamped URL changes only when the file changes.
  3. Rely on the one thing the edge does not hold onto. index.html is never edge-cached, so it is always fresh, and it points at a URL the browser has never seen and therefore cannot have stored.
  4. Verify after every deploy by following the same path a browser follows. Fetch the HTML, grep the stamped URL out of it, then fetch that exact stamped URL and grep it for whatever you just added.
  5. For pages and files already cached with old bodies, bypass the cache at the zone. Cache Purge was not available on the API token in use and returns error 10000, so the workaround that worked was Development Mode, which bypasses cache for three hours and expires by itself:
    curl -X PATCH "https://api.cloudflare.com/client/v4/zones/<zone_id>/settings/development_mode" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" -d '{"value":"on"}'
  6. Re-verify after both windows have passed. Objects already cached age out on their own clock, which can be later than the end of Development Mode, so the check that matters is the one run after the later of the two. Track so the second check is scheduled rather than guessed.

Never reuse an asset filename

Put the hash in the build, never in a hand edit. A stamped URL that a person has to remember to bump is a stamped URL that will be stale on the deploy that matters.

Make the verification step read the stamped URL out of the HTML rather than assume the filename. Checking app.v3.js directly tests a URL no visitor requests, and it passes while visitors are served something else.

Use a fresh nonsense path each time you probe. A probe URL you have used before may be holding a cached response, which is how a wrong URL ends up returning a homepage from hours ago and gets mistaken for a routing change.

Confirm the API token's permissions before an incident needs them. Finding out that Cache Purge returns 10000 is cheap on a quiet day and expensive mid-deploy.

The decision rule. A correct file on the server plus old behaviour in a browser means a caching problem, not a deploy problem. Redeploying tests nothing, because the deploy already succeeded.

Old copies of unstamped files

Content-hashing the URL does not change how Pages serves the asset. The four hour max-age is still applied, and anyone holding an old copy of an unstamped URL keeps it until their own clock runs out.

Development Mode does not undo caching that already happened. It bypasses cache for new requests for three hours, while objects stored before it was switched on continue to age on their own schedule, so a path can still serve a stale body after Development Mode has ended.

None of this restores Cache Purge on a token that returns error 10000. That is a token scope problem and it needs a token with the right permission.

It does not reach a service worker either. If the site registers one, it holds its own copy under its own rules and has to be versioned separately.

Finally, a _headers file remains inert for every request that a Pages Function middleware answers. Making it work again means not routing those paths through middleware, which is a different decision with different consequences.

Questions about stale JavaScript after a deploy

Why does the browser keep the old JavaScript when the server has the new file?

Cloudflare Pages serves .js and .css with Cache-Control: max-age=14400, four hours with no revalidation. A browser holding a response under that header does not ask whether the file changed, so it keeps running the copy it already has.

Can I fix this with a _headers file?

Not when every request passes through a Pages Function middleware. Pages honours _headers only when it serves an asset directly, and extension globs like /*.js do not match there in any case.

Why does setting Cache-Control in the middleware not work for scripts?

Cloudflare's edge caches those extensions and stamps its own max-age=14400 over whatever the Worker set. You can see it in the headers: /index.html returns cf-cache-status: DYNAMIC and honours the Worker header, while /app.v3.js returns cf-cache-status: MISS with max-age=14400 regardless.

What should I do if Cache Purge is not available on my API token?

A token without that permission returns error 10000. Switching the zone's Development Mode on bypasses cache for three hours and expires by itself, which was the workaround that worked.

How do I verify a deploy actually reached visitors?

Follow the path a browser follows. Fetch the HTML, grep the stamped asset URL out of it, then fetch that exact stamped URL and grep it for the change you just made. Checking the unstamped filename tests a URL nobody requests.

Does this affect whole pages or only scripts?

Both. On one deploy the homepage updated immediately while /robots.txt, a deliberately wrong probe URL and a newly added key file kept returning the old homepage HTML for hours, because those responses had already been cached at the edge with max-age=14400.