Cloudflare Cache vs Server Cache: What’s Actually Being Cached?
Jesse Cole
September 27, 2026
A client called me because their website “wouldn’t update.” They had edited the opening hours on their contact page, hit save, and the old hours were still showing an hour later. They’d already clicked “Purge Everything” in Cloudflare twice. Still the old hours.
Cloudflare wasn’t the problem. It wasn’t caching that page at all. The stale copy was coming from a page cache plugin on their WordPress server, which had a 12-hour lifetime and didn’t clear itself when that particular page was edited. The Cloudflare purge had done exactly what it said, on a cache that didn’t hold the page.
A month later the same client had the opposite problem. A plugin update cleared the server cache, a newsletter went out at the same moment, and the site slowed to a crawl. They were confused, because “we have Cloudflare, it caches everything.” It didn’t. Cloudflare had been caching their images, CSS, and JavaScript. Every HTML page had been generated or served by the origin server the whole time, and the only thing protecting it was the server-side cache that had just been wiped.
Both incidents came down to one question nobody had asked: which cache actually served this response? This article is about how to answer that, because once you know, most caching problems become easy to fix.
The layers between a visitor and your code
On a typical small site behind Cloudflare, a request can be answered by any of these, in order:
- The browser’s own cache. If the browser has a fresh copy, it doesn’t send a request at all.
- Cloudflare’s edge cache in the data center nearest the visitor.
- A reverse proxy cache on your server, like Nginx’s
proxy_cacheorfastcgi_cache, or Varnish. - An application page cache, like a WordPress caching plugin writing HTML files to disk, or a framework’s full-page cache.
- Your application actually running and building the response.
And underneath the application, there are caches that don’t store responses at all but get confused with the ones above:
- An object cache (Redis or Memcached) stores the results of database queries or computed values. It makes step 5 faster. It never serves a page on its own.
- OPcache in PHP stores compiled code. It makes PHP start faster. It has nothing to do with your content.
- The database’s own memory cache keeps frequently read data in RAM.
When someone says “the site is cached,” they could mean any of these. When a change doesn’t show up, it could be stuck in any of the first four. Purging the wrong one does nothing, which is exactly what happened to my client.

What Cloudflare caches by default
This is the most common misunderstanding. Out of the box, Cloudflare caches static files based on their file extension: images, CSS, JavaScript, fonts, and similar. It does not cache HTML by default. Your pages pass through Cloudflare to your server on every request unless you explicitly configure a cache rule to make HTML eligible for caching.
Even for files it would cache, Cloudflare respects what your server tells it. Responses marked Cache-Control: private or no-store won’t be cached, and responses that set cookies generally won’t be either. So a site can have Cloudflare turned on for years with the HTML never cached once.
The dashboard’s “cached requests” percentage can make this look better than it is. A page with 30 cached assets and one uncached HTML document shows a high cache ratio by request count. But the one uncached request is the one that hits your server, runs your code, and determines time to first byte.
How to see which cache answered
You don’t need special tools. The response headers tell you almost everything. From a terminal:
curl -sI https://example.com/contact/
Or open your browser’s developer tools, go to the Network tab, click the document request, and look at the response headers. Here’s what to read.
cf-cache-status: what Cloudflare did
This header is added by Cloudflare to every proxied response. The values that matter most:
- HIT: served from Cloudflare’s cache. Your server wasn’t contacted.
- MISS: eligible for caching but not in cache at this data center yet. Cloudflare fetched it from your server and will likely cache it now.
- EXPIRED: was in cache but stale, so Cloudflare fetched a fresh copy from your server.
- REVALIDATED: the cached copy was checked with your server and confirmed still valid.
- BYPASS: your server’s headers told Cloudflare not to cache it (for example, a cookie or
private/no-cache). - DYNAMIC: Cloudflare doesn’t consider this cacheable at all under your current settings. This is what you’ll see on HTML pages by default.
If the page you care about shows DYNAMIC or BYPASS, Cloudflare is not caching it, and purging Cloudflare won’t change what you see. That was the answer for my client’s contact page, visible in about ten seconds once someone looked.
Age: how long it’s been cached
An Age header shows how many seconds a cached copy has existed. If you see cf-cache-status: HIT with Age: 3400, that copy is nearly an hour old. Some server-side caches set this too.
cf-ray: which data center
The cf-ray header ends with a code for the Cloudflare data center that handled the request, like FRA or SYD. This matters because Cloudflare’s cache is per data center. A page can be a HIT in Frankfurt and a MISS in Sydney at the same time. If a colleague in another country sees something different from you, this is often why.
Your server’s own cache headers
Server-side caches don’t announce themselves unless you ask them to. This is the part most setups miss, and it’s why my client’s page cache was invisible. Make each layer add a header:
- Nginx:
add_header X-Cache-Status $upstream_cache_status;in the location that usesproxy_cacheorfastcgi_cache. You’ll see HIT, MISS, BYPASS, EXPIRED, and so on, just like Cloudflare’s header. - Varnish: commonly adds
X-VarnishandAge, and many configs add an explicit hit/miss header. - WordPress page cache plugins: most add either a response header or an HTML comment at the bottom of the page with a timestamp. View the page source and scroll to the end.
Once each layer labels its responses, a single curl -sI tells you exactly where the answer came from.

Reading the combinations
Put the headers together and the picture becomes clear:
- cf-cache-status: HIT: Cloudflare answered. Nothing on your server ran. If the content is stale, purge Cloudflare (that URL, ideally, not everything).
- cf-cache-status: DYNAMIC or BYPASS, X-Cache-Status: HIT: Cloudflare passed the request through, and your server’s cache answered. If the content is stale, purge the server cache. Purging Cloudflare does nothing. This was my client’s contact page.
- cf-cache-status: DYNAMIC, X-Cache-Status: MISS (or no server cache header at all): your application built this response from scratch. This is your slowest path, and under traffic it’s the one that falls over. This was my client’s newsletter morning.
- cf-cache-status: MISS or EXPIRED followed by HIT on the next request: normal. The first visitor at a data center pays the cost, later visitors get the cached copy.
- No request in the Network tab at all, or “from disk cache”/”from memory cache”: the browser answered. A hard refresh or a private window skips this layer when you’re testing.
Which cache should hold what
After sorting this out for a few sites, this is the split I use by default:
- Static assets (images, CSS, JS, fonts): cached at Cloudflare and in the browser, with long lifetimes and versioned filenames so a deploy changes the URL instead of needing a purge.
- Public HTML that’s the same for everyone (blog posts, marketing pages, docs): cached on the server for sure, and at Cloudflare too if you set up a cache rule for it, with a bypass for logged-in users based on a cookie. Caching it at the edge takes load off your server, and whether visitors actually see a faster page mostly comes down to how far they are from your origin.
- Personalized HTML (carts, dashboards, account pages): not cached at Cloudflare at all. Make sure these send
Cache-Control: privateso no shared cache ever stores one visitor’s page and serves it to another. That’s the worst caching bug there is, and it’s much more likely when nobody knows which layer is doing what. - Expensive data behind the pages: an object cache like Redis, so even an uncached page render is fast.
Making purges hit the right layer
The fix for “my change doesn’t show up” is making sure an edit clears every layer that holds that page:
- When content changes, the application should purge its own page cache for that URL. Most WordPress cache plugins do this for the edited post, but not always for related pages like the homepage, category archives, or a contact page included in a widget.
- If Cloudflare caches HTML, the same event should purge that URL at Cloudflare through its API. Several WordPress plugins can do this; for other stacks it’s a short API call in a deploy or save hook.
- For static assets, avoid purging entirely by changing filenames on deploy.
For my client, the fix was two changes. The page cache plugin was configured to clear the contact page and homepage on any edit, and Nginx got an X-Cache-Status header so the next person debugging could see it. For the traffic problem, we added a Cloudflare cache rule for public pages with a short edge lifetime and a cookie bypass for logged-in editors. The next newsletter went out with most HTML requests showing cf-cache-status: HIT, and the server barely noticed.
The habit worth building
Whenever a site behaves strangely, whether that’s stale content, content that’s suddenly slow, or one person seeing something different from another, check the headers before touching any settings. Run curl -sI on the URL, read cf-cache-status, read your server’s cache header, and note the data center in cf-ray. It takes less than a minute, and it tells you which cache you’re dealing with before you purge anything.