Two layers, two different kinds of stale

When a change “isn’t showing”, there are two separate questions, and they have different answers and different fixes:

  1. Which server does the name lead to? That’s DNS. Stale DNS means a visitor’s resolver still has the old record cached and sends them to the old server.
  2. What does that server return? That’s HTTP caching. A stale cache means the visitor reaches the right place, a CDN edge or a reverse proxy, and it hands back a copy it stored before your change.

Then there’s a third layer people forget: the visitor’s own browser, with its HTTP cache, service workers and remembered redirects.

The fixes don’t transfer. Purging a CDN does nothing for a resolver that sends people to your old host. Waiting out a DNS TTL does nothing for an object cached with max-age=86400. So find the layer first.

One thing that changes the picture: if your site is behind a CDN proxy, DNS often doesn’t change at all. With a proxied Cloudflare record, the public answer is Cloudflare’s addresses whether your origin is in one data centre or another, and changing the origin takes effect at the edge directly. What people call “CDN propagation” is then one of two things: a configuration change still rolling out to the CDN’s edges, or cached objects that haven’t expired.

Step 1: is DNS settled?

Compare what your authoritative nameserver says with what public resolvers have cached:

$ dig @ns1.dns-provider.example www.example.com A +norecurse +short
198.51.100.20

$ dig @1.1.1.1 www.example.com A +short
198.51.100.20

$ dig @8.8.8.8 www.example.com A +short
203.0.113.5

Here Google’s resolver still has the old address. That’s DNS, and it’ll converge when that cached copy’s TTL runs out (dig @8.8.8.8 www.example.com A without +short shows the seconds remaining). The propagation check does the same comparison across six public resolvers at once, and the propagation guide covers how long it takes.

Behind a CDN, comparing IPs can mislead. CDNs answer with different addresses in different places on purpose, so resolvers disagreeing is normal. Compare the CNAME target instead (dig www.example.com CNAME +short), or check that every IP you get belongs to the CDN; the ASN lookup tells you who owns an address. If all answers point into the CDN, DNS is not your problem.

Step 2: take DNS out of the test

curl --resolve connects to an IP you choose while sending the right hostname and SNI, so you can ask a specific server directly:

# The new origin, bypassing DNS and the CDN
curl -s --resolve www.example.com:443:198.51.100.20 https://www.example.com/ | grep -o '<title>.*</title>'

# What a normal visitor gets, and which IP they actually reach
curl -sv -o /dev/null https://www.example.com/ 2>&1 | grep -iE 'connected to|^< (age|cache-control|cf-cache-status|x-cache)'

If the request pinned to the new origin returns the new content and the normal request returns the old, the difference is in between: DNS (if the normal request connected to the old IP) or a cache (if it connected to the CDN). If the request pinned to the new origin also returns old content, nothing is stale. The deploy didn’t happen, went to a different server, or the application has its own cache.

If your origin only accepts connections from the CDN’s IP ranges, run the pinned request from the origin itself against 127.0.0.1.

Step 3: read the cache headers

A cached response says so, if you know where to look:

$ curl -sI https://www.example.com/
HTTP/2 200
date: Wed, 07 Oct 2026 08:12:40 GMT
content-type: text/html; charset=utf-8
cache-control: public, max-age=3600
age: 2841
cf-cache-status: HIT
last-modified: Wed, 07 Oct 2026 07:20:02 GMT

Age is the standard header: how many seconds the response has been sitting in a cache. Here it’s been cached for 47 minutes, under a max-age of an hour, so this edge will keep serving it for about another 13 minutes unless you purge. An Age above zero means the copy came from a cache, whatever else the headers say.

Each CDN also reports hit or miss in its own header:

CDNHeaderValues to look for
Cloudflarecf-cache-statusHIT, MISS, EXPIRED, STALE, UPDATING, REVALIDATED, BYPASS, DYNAMIC
Amazon CloudFrontx-cacheHit from cloudfront, Miss from cloudfront, RefreshHit from cloudfront
Fastlyx-cache, x-served-byHIT or MISS, one per cache layer (MISS, HIT)
Vercelx-vercel-cacheHIT, MISS, STALE, PRERENDER
Any cache following RFC 9211cache-statuse.g. ExampleCDN; hit

Akamai doesn’t show cache status by default; it adds debug headers when the request includes Akamai’s Pragma debug values, such as akamai-x-cache-on.

Two values change the diagnosis:

  • cf-cache-status: DYNAMIC means Cloudflare didn’t consider the response cacheable and fetched it from your origin. Cloudflare doesn’t cache HTML by default, so for most pages this is what you’ll see. If the content is old and the status is DYNAMIC, the CDN isn’t the cause; look at the origin or the browser.
  • STALE (or stale-while-revalidate / stale-if-error behaviour in general) means the CDN served an expired copy on purpose, often because the origin failed to answer. That looks like “my change didn’t deploy” and is really “my origin is down”. Test the origin directly.

The HTTP check fetches the URL from outside your network and lists every response header, so you can see Age, cache-control and the CDN’s status without a terminal. It’s one vantage point: a CDN has many edges, and another edge can hold an older or newer copy.

A quick extra test: add a query string nobody has used, like ?nocache=8231. If that returns new content, a cache keyed on the full URL was serving the old copy. If it doesn’t, the cache may be ignoring query strings (a common CDN setting), or there’s no cache involved.

Step 4: rule out the browser

If curl gets the new version and your browser shows the old one, the stale copy is on your machine.

  • HTTP cache. Open DevTools, Network tab, tick “Disable cache” and reload. The Size column shows (memory cache) or (disk cache) for responses that never left the browser.
  • Service worker. Sites with offline support or PWA features can serve pages from a service worker’s cache. The Size column shows (ServiceWorker). DevTools → Application → Service Workers lets you unregister it or bypass it for the network.
  • A cached redirect. Browsers keep 301 redirects for a long time. If you removed a redirect and the browser still follows it, that’s why. A private window shows the real current behaviour.

A private window is the fastest check for all three. It starts without the normal profile’s cache, cookies or service workers.

Putting it together

Authoritative DNSPublic resolversPinned to new originNormal requestStale layer
NewSome oldNewOld for some usersDNS. Wait for the old TTL.
NewAll new (or all CDN)NewOld, Age > 0, cache HITCDN or proxy cache. Purge.
NewAll new (or all CDN)OldOldNothing is stale; the origin serves old content. Check the deploy.
NewAll new (or all CDN)NewNew in curl, old in browserBrowser cache, service worker or cached redirect.
OldOldn/aOldThe change was never published, or made in a zone that isn’t live. See the nameserver mismatch guide.

Purge or wait

DNS: wait. You can’t purge other people’s resolvers. Cloudflare and Google offer pages to flush a single name from 1.1.1.1 and 8.8.8.8, which helps their users only. The old record’s TTL is the real limit, which is why the TTL guide recommends lowering it a day before a planned change.

CDN: purge, and purge narrowly. The CDN won’t refresh an object before its freshness runs out, and that can be days. Purge the URLs, prefixes or cache tags you changed rather than everything; “purge everything” sends every request to your origin at once, which is a bad thing to do to a struggling origin.

# Cloudflare: purge specific URLs
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"files":["https://www.example.com/","https://www.example.com/app.css"]}'

# CloudFront: invalidate paths
aws cloudfront create-invalidation --distribution-id E1EXAMPLE --paths "/" "/app.css"

Purges take a little while to reach every edge, and CloudFront charges for invalidation paths beyond a monthly free allowance. Check the response headers again afterwards: the first request should be a MISS, the next a HIT with a small Age.

Browsers: you can’t purge them. Whatever Cache-Control you sent is what visitors’ browsers obey. If you served app.js with max-age=31536000, returning visitors keep it for a year. The durable fix is in how you name and cache files:

  • Put a content hash in static asset filenames (app.3f9a1c.js) and cache those for a long time; a changed file gets a new name.
  • Serve HTML with a short max-age or Cache-Control: no-cache, which means “revalidate before use”, not “don’t store”. Then a new deploy shows up on the next page load.

The caching and compression checklist covers headers in more detail.

When DNS and the CDN both changed

Moving a site onto a CDN, or moving the origin behind one, changes two layers in one go. Separate them:

  • Moving onto a CDN changes your DNS record (to a CNAME or the CDN’s IPs). That part follows the old record’s TTL. Once traffic arrives, the CDN’s cache starts empty, so expect MISS and a load spike at the origin first.
  • Moving the origin behind the same CDN usually doesn’t change public DNS. If the CDN reaches your origin by IP (Cloudflare’s proxied A record, for instance), the switch is immediate. If it reaches the origin by hostname, the CDN resolves that hostname itself and caches it under its TTL, so lower that TTL before the move.
  • Changing CDN configuration (rules, origins, behaviours) has its own rollout to the edges. CloudFront shows a distribution as “Deploying” until that’s done. During it, different edges behave differently, which looks like propagation and isn’t DNS.

Test one layer at a time, with the commands above, before deciding what to purge or how long to wait.

Check your own domain now

Free, no sign-up. Runs the exact check this guide describes and shows what to fix.