The deploy finished. The database is updated. The origin server, if you curl it directly, returns the new content. And a real visitor, in a real browser, still sees the old page — sometimes for minutes, sometimes for most of a day. Nobody broke anything. Three separate caches sit between that origin and that visitor, and each one is doing exactly what it was told, which is the actual problem: nobody told all three of them the same thing.
What are the three layers, actually?
Layer one is the browser's own HTTP cache, sitting on the visitor's device. It reads the Cache-Control header on a response and, if that header says a resource is good for an hour, simply doesn't ask the server again for an hour — not because the server is slow to respond, but because the browser was told not to bother asking. Layer two is a CDN edge cache — Cloudflare, in most self-hosted setups — sitting between the origin and every visitor, caching a copy of a response so the next thousand visitors from the same region hit the edge instead of the actual VPS. Layer three is Next.js's own server-side cache: ISR output and cached data, stored on the origin itself, keyed by route and by cache tag, and invisible to the other two layers entirely. I write about browser cache vs CDN cache vs next.js cache, explained from the build side; XenGrowth's marketing operations practice covers what it takes to run it.
These aren't three names for the same mechanism wearing different hats. They're three separate stores, controlled by three separate things, and a change that clears one of them does nothing to the other two unless someone explicitly wires that up.
Layer | Where it lives | What clears it |
|---|---|---|
Browser cache | The visitor's own device | The Cache-Control max-age expiring, or a hard refresh — nothing the server does after the fact reaches it |
CDN edge cache | Points of presence between the origin and every visitor | A cache purge call to the CDN's API, or the edge TTL expiring on its own |
Next.js server cache | Local disk (or a custom cache handler) on the origin itself | revalidateTag / revalidatePath, or the ISR revalidate window elapsing |
What Cache-Control value does each kind of page actually get?
This is the part worth memorizing rather than guessing at, because the header is the only thing the browser and the CDN ever actually see — neither of them knows anything about ISR or revalidate windows except through this one line.
Response type | Cache-Control header | Browser behavior | CDN behavior |
|---|---|---|---|
Immutable static assets (hashed filenames — JS, CSS, images from static imports) | public, max-age=31536000, immutable | Cached for a year, never re-requested, never revalidated | Cached indefinitely at the edge — safe because the filename itself changes on any real change |
ISR / revalidated pages | s-maxage=<revalidate>, stale-while-revalidate | Respects max-age if present, otherwise treats it like a normal response | Serves the cached copy instantly while revalidating in the background, if the CDN respects stale-while-revalidate |
Dynamically rendered pages | private, no-cache, no-store, max-age=0, must-revalidate | Never cached — every request re-validates or re-fetches | Never cached — a CDN has no legal basis to cache a private response, full stop |
That third row is where "it's still slow even with a CDN in front" usually comes from. A CDN can't cache a response marked private, no-cache, no-store — that header exists specifically to say don't cache this, and every well-behaved CDN honors it. If a page is rendering dynamically because it reads cookies, headers, or search params, it's getting that header whether or not a CDN sits in front of it, and the CDN is doing precisely nothing for that route. That's not a CDN failure. That's the CDN correctly refusing to cache something it was told not to. If the operations side of this is the part you are stuck on, The XenGrowth resource library is the better reference.
So why does a deploy stay stale for hours specifically?
Because the CDN edge cache and the browser cache both operate on their own clocks, independent of whatever just happened at the origin. If an ISR page was cached at the edge with a stale-while-revalidate window, and the CDN's own minimum cache floor is higher than the page's own revalidate time, the CDN's floor wins — Cloudflare's free plan, for instance, can't set an edge cache TTL below two hours through cache rules, no matter what the origin's own header says the page's revalidate interval should be. A page configured to revalidate every five minutes at the Next.js layer can still sit unchanged at the edge for two hours, because the CDN's own rule floor is the thing actually governing that copy, not the origin's more generous intention.
revalidateTag() or revalidatePath() runs on the origin and clears Next.js's own on-disk cache — it does not touch a CDN edge cache or any visitor's browser cache
A CDN purge clears the edge cache — it does not touch Next.js's own server cache, which will just regenerate the same content it already had if nothing else changed
A hard refresh clears one visitor's browser cache — it touches nothing upstream of that one browser
None of these three actions is a substitute for either of the other two; a real "push this change live everywhere immediately" flow has to trigger all three deliberately
What about multiple app instances — does that change anything?
Yes, and it's a separate problem from the CDN layer entirely. Next.js's caching and ISR share one server cache, stored on local disk per instance by default — fine for a single self-hosted instance, but each additional instance keeps its own separate copy of that cache unless a custom cache handler is configured to share it. Calling revalidateTag on instance A clears instance A's cache; instance B, sitting behind the same load balancer, keeps serving its own stale copy until it independently notices the change or its own revalidate window elapses. The fix Next.js documents is a custom cacheHandler backed by shared storage — Redis is the common example — with cacheMaxMemorySize set to 0 so the in-memory default (which caps at 50 MB per instance anyway) isn't fighting the shared store. This is exactly the same shape of problem as the CDN one — one clock per layer, and every layer needs to be told separately.
A worked example: a page that revalidates every five minutes
Say a product page is set to revalidate every 300 seconds. Next.js sends s-maxage=300, stale-while-revalidate. At the origin, that's exactly what happens: the page regenerates in the background roughly every five minutes, and Next.js's own on-disk cache holds whatever the latest generated copy is. So far, so simple — one layer, one clock, behaving exactly as configured. Now put a CDN with a two-hour minimum cache floor in front of it. The CDN caches its own copy of that response the first time it's requested, and holds onto it for its own two-hour minimum regardless of what the origin's s-maxage said it would prefer. For nearly two hours, every visitor hitting that CDN region gets the same cached copy, even though the origin has quietly regenerated that page twenty-some times in the background during the same window. The origin is doing exactly what it was told. The CDN is doing exactly what it was told. Neither one is broken, and the page is still stale for two hours from a visitor's point of view. XenGrowth on governed AI marketing workflows covers the AI agents and marketing automation side of this.
This is the scenario that produces the most confused bug reports in any self-hosted setup, because every individual piece of evidence looks correct in isolation. Curling the origin shows the new data. Checking the Next.js logs shows ISR regenerating on schedule. And a real visitor, five minutes after a price change, still sees the old price — not because anything crashed, but because the CDN's own rules are a second, independent cache with its own independent clock, sitting on top of a Next.js cache that was never in the loop on the CDN's decision to begin with.
Does a managed platform actually avoid this, or just hide it better?
Mostly the latter. A platform that owns both the origin and the CDN edge can wire revalidateTag to also purge the specific edge cache entry for that tag, in one call, because both systems are theirs to coordinate. That's a real advantage, and it's the thing self-hosted setups have to build deliberately instead of getting for free. It isn't a fundamentally different caching model, though — it's the same three-layer structure, with one company operating two of the three layers and making sure they talk to each other. A self-hosted stack with Next.js on a VPS and Cloudflare in front is running the identical architecture; it just has to call Cloudflare's purge API explicitly as part of a deploy or a content update, instead of that call being bundled invisibly into a platform's own revalidation path.
That distinction matters because it changes what "fixing" this actually means. It's not that self-hosting is missing a feature Next.js only unlocks on a managed platform — the three layers, the headers, and the invalidation rules are identical either way, right down to the exact Cache-Control strings. What's missing on a self-hosted stack is the glue code connecting them, and that glue code is something any team can write, because none of the three layers is doing anything proprietary. The gap is real, but it's a gap in wiring, not in capability. On AI search, GEO and discovery specifically, XenGrowth on building one SEO and GEO content system is worth reading.
A deploy hook that also calls the CDN's purge endpoint for changed paths closes most of this gap without waiting out the TTL floor
Cache tags at the CDN layer (where the CDN supports them) let a purge be scoped to exactly what changed, instead of purging the whole zone for one content update
For content that changes on a predictable schedule, setting the CDN's own cache rule TTL to match the ISR revalidate window — where the plan allows going that low — keeps the two clocks from fighting each other
For anything that must be visible everywhere the instant it changes, the honest fix is marking that specific route dynamic rather than fighting three layers of cache to make it look instant
A stale page after a correct deploy isn't proof something's broken. It's proof three clocks were never actually synchronized in the first place.
How do you actually debug "it's stale for some people but not others"?
Start from the origin and work outward, because the layers only ever get more stale as a request moves further from the server, never less. Curl the origin directly — if that's already wrong, none of the caching layers are the problem, the render itself is. If the origin's correct, check what Cache-Control header the response actually carries — a dynamic page marked no-store shouldn't be cached anywhere, so if it's stale, something else changed (a database read that isn't actually fresh, or a request hitting a different instance with its own uncoordinated cache). If the header says it should be cacheable and it's stale at the edge, that's a CDN purge that didn't happen, or a TTL floor like Cloudflare's two-hour minimum still running out its clock. If it's fine at the edge and stale for one specific visitor, that's their browser holding onto a cached copy under the max-age it was given, and nothing server-side is going to reach in and clear it for them.
The instinct to blame the CDN first is understandable — it's the layer people know exists and don't fully understand — but it's usually the least likely culprit of the three, precisely because it's the one most rigidly following the rules it was given. The header is either telling it to cache or telling it not to; there's no third option where a CDN just decides to be slow for no reason.
Further reading from XenGrowth
The XenGrowth resource library — what you'll learn: how the commercial side of this work is run, across search, automation and revenue operations.
XenGrowth on governed AI marketing workflows — what you'll learn: how the teams running AI marketing agents keep them governed and measurable.
XenGrowth on building one SEO and GEO content system — what you'll learn: how search and AI-answer visibility get run as a single content system.
Where this work meets go-to-market
Working on browser cache vs CDN cache vs next.js cache, explained inside a commercial team? XenGrowth's growth operations team publishes operator guides on the revenue side of this work.
Five questions on which cache actually clears when you think you've invalidated everything. Answers and reasoning at the end.











