You fix a typo in a published post, reload, and it's fixed. Then someone replies with a screenshot of the old version.
Something between the host and that reader is serving a stored copy. It's one of four places: the reader's browser, the host's CDN edge, a page cache on the host's servers, or a copy kept on some other service entirely (a social preview, a feed reader, Google). The first three show up in the post's response headers, so that's where I start.
On October 20, 2026 I pulled the headers from public blogs on nine platforms. Edge lifetimes went from 25 seconds on Hashnode to 12 hours on Bear. And the usual trick of adding a query string to bust the cache only worked on two of the four CDNs I tried it on.
One curl command
Run it against the public URL, from a terminal that isn't logged in to the blog:
curl -s -o /dev/null -D - https://yourblog.com/your-post/ \
| grep -iE '^(cache-control|age|cf-cache-status|x-vercel-cache|cache-status|x-cache|last-modified):'
What I look at:
-
cache-control: max-age=Nlets a browser reuse its copy for N seconds without asking.s-maxage=Ndoes the same for shared caches like a CDN, and RFC 9111 says it overridesmax-agethere. -
no-cacheormax-age=0means a stored copy has to be revalidated before reuse. It does not mean "don't store it", which MDN's caching guide is clear about. -
ageis how many seconds the copy has been sitting in a shared cache.age: 5961is an edge copy about 100 minutes old. - The cache-status header tells you if the edge answered from storage. The name depends on the CDN:
cf-cache-statuson Cloudflare,x-vercel-cacheon Vercel,cache-statuson Netlify,x-cacheon Fastly.HITmeans a stored copy.
What nine platforms sent
One or two requests per platform, to a real site on it. It's a snapshot... hosts change these, and individual sites add their own rules. Medium gave me a 403, so it's not here.
| Platform | Site checked | Cache-Control on the post HTML | Edge signal | How long an old copy can live |
|---|---|---|---|---|
| GitHub Pages | karpathy.github.io, jekyllrb.com | max-age=600 |
x-cache via Fastly (Varnish) |
10 minutes in a browser that already loaded it |
| Netlify | docs.netlify.com | public, max-age=0, must-revalidate |
cache-status: "Netlify Edge" |
Until the next deploy, which Netlify says clears the CDN cache |
| Vercel | nextjs.org/blog | public, max-age=0, must-revalidate |
x-vercel-cache: HIT, age: 7426
|
Until the next deployment, which gets its own cache key |
| Hashnode | townhall.hashnode.com | public, s-maxage=25 |
x-vercel-cache: MISS |
25 seconds at the edge |
| Ghost(Pro) | 404media.co | public, max-age=0 |
x-cache: MISS, HIT, HIT, age: 8531
|
Browsers revalidate every time; the edge copy depends on Ghost(Pro) purging it |
| WordPress.com | wordpress.com/blog |
max-age=137, must-revalidate (156 on an earlier request) |
x-nananana: Batcache-Hit |
About two and a half minutes in a browser |
| Substack | on.substack.com | no-cache |
cf-cache-status: HIT, age: 1470
|
Edge copy kept despite no-cache; lifetime not exposed |
| Bear | herman.bearblog.dev | public, s-maxage=43200, max-age=0 |
cf-cache-status: HIT, age: 5842
|
Up to 12 hours at the edge unless Bear purges on edit |
I couldn't find current docs on when Ghost(Pro) or Bear purge the edge after an edit. The closest thing is a 2019 reply on the Ghost forum saying their custom X-Cache-Invalidate header is used on Ghost(Pro)'s caching layer. So the header test on your own post is the only reliable answer I have.
Step 1: rule out the browser
Only GitHub Pages and WordPress.com in that table give browsers a positive max-age. Inside that window, a browser that already has the page can show its stored copy when you click back to the post.
A normal reload sends Cache-Control: max-age=0 with the stored validators, so the browser checks with the server first. A force reload (Ctrl+Shift+R, Cmd+Shift+R on a Mac) sends no-cache and skips the stored copy. If either one shows the edit, it was the browser, and everyone else's browser catches up within the max-age. That's 10 minutes at most on GitHub Pages.
Step 2: check the edge copy's age
If a force reload still shows the old text, and the response has a HIT with an age longer than the time since your edit, the edge copy was stored before you saved. How long it stays is s-maxage or whatever rules the CDN has. On Bear that's 43,200 seconds.
Substack's row is the odd one. Cloudflare's docs say it doesn't cache HTML by default and skips responses marked no-cache. So a HIT on no-cache HTML means Substack set its own cache rule, with a lifetime the response doesn't tell you.
To confirm the edge is the problem, request the post with a throwaway query string like ?nocache=21404. The result depended on the CDN:
| Platform | Plain URL | With a random query string |
|---|---|---|
| Bear |
HIT, age: 5961
|
MISS |
| Substack |
HIT, age: 1600
|
MISS |
| GitHub Pages | HIT |
HIT |
| Ghost(Pro) |
HIT, age: 8531
|
HIT, age: 8651
|
On Bear and Substack the query string went to the origin. If that version has your edit and the plain URL doesn't, the edge copy is stale and gets replaced when it expires or the host purges it.
On GitHub Pages and Ghost(Pro) it still came back as a hit, and on Ghost(Pro) the age kept counting up. The cache was ignoring the query string. On those hosts this test can't tell the edge from the origin, which I think a lot of "just add ?v=2" advice misses.
If you put your own Cloudflare proxy in front of a hosted blog, you now have two edge caches with separate rules stacked on each other. Check both.
Step 3: check logged out
Page caches often treat signed-in visitors differently. The Batcache readme, which is the cache behind Batcache-Hit on WordPress.com, says it works "by serving old pages to new users," and that once visitors "have logged in or left a comment they will always get fresh pages."
So if you're signed in to your own blog, you see the edit and logged-out readers get the stored page. A private window or the curl above carries none of your cookies.
Step 4: the copies that aren't on your host
If the post URL serves the new version and someone still sees the old one, the copy lives on another service, and each one refreshes on its own schedule:
- Link previews on social networks and chat apps keep the title, description and image they fetched first.
- Feed readers only see the edit after they poll your feed again, which can be hours for a small blog.
- Google's title and snippet come from its last crawl. Search Console's URL Inspection tool lets you run a live test and request indexing. Google says that "typically takes only a day or so" but can take longer, and requests have a daily limit.
I build JustBlogged, and when I ran the same curl on its post pages they came back with no Cache-Control, no age, no cache-status header and no Last-Modified. Nothing tells a browser or CDN to keep the page, so when someone there reports an old version it's always step 4. The flip side is every request goes to the origin, and I'm still not sure that's the right default
Top comments (0)