CMS Caching Explained: Page, Object, and Browser Caching
The five cache layers behind every fast CMS site — and why 'clear the cache' fixes everything
"Have you tried clearing the cache?" If you've run a CMS for more than a week, someone has said this to you. It fixes the broken stylesheet, the stale menu, the page that won't update after you edited it. The advice works so often because CMS caching is not one thing — it's four or five separate layers stacked on top of each other, and any one of them can serve you yesterday's content. This guide walks through each layer: what it stores, how long it should live, and how it goes wrong.
TL;DR: CMS caching has five layers — page cache, object cache, query cache, browser cache, and CDN. Most "why won't my changes show up" problems are a cache invalidation miss at one of these layers. This guide explains each one and when to use it.
By the end you'll know which cache to clear (instead of nuking all of them), what TTLs make sense for each layer, and how to avoid the three pitfalls that bite almost every CMS site at some point.
Why CMS pages are slow without caching
A database-driven CMS rebuilds every page from scratch on every request. Trace what happens when someone loads a single blog post:
- The router matches the URL and resolves the slug to a post ID — one query.
- Site settings load: site name, logo, SEO defaults, analytics keys — often 1 query per setting if the CMS is naive, or one batched query if it's smart.
- The navigation menu loads — menu items, nested children, linked page slugs.
- The post itself loads, plus its author, categories, tags, and featured image.
- Related posts, comment counts, sidebar widgets — more queries.
- PHP renders all of it through templates into HTML.
A typical CMS page fires 30 to 80 database queries. Each one might take 1–5 ms, which sounds harmless until you add template rendering and framework boot time on top. Uncached, a CMS page commonly takes 200–600 ms of server time before the first byte even leaves the box. Under traffic, the database becomes the bottleneck and those numbers get worse, not better.
This is the core trade-off between database-driven and flat-file systems — we compared the two in flat-file CMS vs database CMS. Flat-file systems skip the database but give up querying. Caching is how a database CMS gets the speed of static files without giving up dynamic content.
The key insight: most of that work produces the same result every time. The menu doesn't change between requests. The post body doesn't change. Caching is just the act of doing the work once and reusing the answer.
The five caching layers
Each layer caches a different thing, lives in a different place, and fails in a different way. From closest-to-the-database to closest-to-the-visitor:
1. Object cache (query and fragment caching)
The object cache stores the results of expensive work — a database query result, a computed settings array, a rendered menu fragment — in fast storage like Redis, Memcached, or even local files. The page still renders on every request, but the expensive parts are pre-answered.
In Laravel this is the Cache facade, and the workhorse is Cache::remember():
$menu = Cache::remember('menu.main', 3600, function () {
return Menu::with('items.children')->where('slug', 'main')->first();
});
First request: run the query, store the result for an hour. Every request after: skip the database entirely. Settings, menus, category lists, and "popular posts" widgets are the classic candidates — read on every page, changed rarely.
Object caching is the safest layer because it's granular. A stale menu for 60 seconds is invisible. It's also the layer that turns 60 queries per page into 5.
2. Page cache (full-response caching)
The page cache stores the entire rendered HTML response. When a request comes in for a cached URL, the server returns the stored HTML without booting most of the application at all. This is the biggest single win available: a 400 ms page becomes a 5–20 ms page.
The catch is that page caching is all-or-nothing per URL. The whole response is frozen — including anything personalized in it. That's why the rule is strict: only cache pages that look identical for every anonymous visitor. Blog posts, docs pages, the homepage — yes. Cart pages, dashboards, anything behind a login — never (more on this in the pitfalls section).
In the Laravel world this is usually done with a middleware that writes responses to disk or Redis keyed by URL, or it's pushed out to the CDN layer entirely and the origin never sees repeat requests.
3. Opcode cache (OPcache)
PHP is interpreted: by default, every request would re-read your PHP files, parse them, and compile them to bytecode before running anything. OPcache stores that compiled bytecode in shared memory so the compile step happens once per deploy instead of once per request.
You almost certainly have it already — OPcache ships enabled in every mainstream PHP setup, including shared hosting. It's worth checking two settings:
opcache.memory_consumption=192
opcache.validate_timestamps=1 ; set to 0 only if you reset OPcache on every deploy
With validate_timestamps=0, PHP never re-checks files for changes — fastest possible, but your deploys must call opcache_reset() or reload PHP-FPM, or the server keeps running the old code forever. This is the cache people forget exists, which makes it a star of the pitfalls section below.
4. Browser cache (Cache-Control headers)
Everything so far makes the server faster. The browser cache makes the request disappear entirely: the visitor's own machine keeps a copy and never asks again until it expires.
The server controls this with the Cache-Control response header:
Cache-Control: public, max-age=31536000, immutable # fingerprinted assets
Cache-Control: public, max-age=300 # HTML, if at all
Cache-Control: private, no-cache # logged-in pages
The pattern that makes long browser caching safe is fingerprinted filenames. Build tools like Vite emit app-Bx81xkpQ.css — the hash changes when the content changes, so you can tell browsers to cache the file for a year. When you deploy new CSS, the filename changes and browsers fetch the new file immediately. No invalidation problem at all.
HTML is different. Cache it in the browser for an hour and visitors who already loaded a page won't see your edit for an hour, with no way for you to push the update. Keep HTML at max-age of a few minutes, or no-cache (which still allows conditional revalidation via ETags — cheap, and always fresh).
5. CDN edge cache
A CDN (Cloudflare, Fastly, CloudFront) is a shared cache that sits between your server and all browsers, with copies in datacenters around the world. It behaves like a browser cache that thousands of visitors share: one visitor in Berlin warms the cache, and the next five hundred get served from Frankfurt without touching your origin.
CDNs respect the same Cache-Control headers, with extras like s-maxage (TTL for the shared cache only) and purge APIs that let you evict a URL the moment content changes — something you can never do with a browser cache. For static assets the CDN is free performance. For HTML it's the most powerful page cache available, with the same logged-in-user caveats.
API responses are good CDN candidates too. UnfoldCMS — our self-hosted Laravel CMS — exposes public read endpoints at /api/v1/* for posts, pages, categories, search, menus, and settings; a headless frontend hitting those endpoints through a CDN with a short s-maxage barely touches the origin. If your frontend is fully static, the natural endgame is rebuilding on publish — see how CMS webhooks trigger frontend rebuilds.
The layers at a glance
| Layer | What it stores | Lives in | Typical TTL | Cleared by |
|---|---|---|---|---|
| Object cache | Query results, fragments | Redis / Memcached / files | 1 min – 1 hour | App on content change, or TTL |
| Page cache | Full HTML responses | Disk / Redis / CDN | 1 min – 1 hour | Publish events, purge API |
| OPcache | Compiled PHP bytecode | Server memory | Until deploy | PHP-FPM reload / opcache_reset() |
| Browser cache | Assets, sometimes HTML | Visitor's device | 1 year (fingerprinted) / minutes (HTML) | Filename change or expiry only |
| CDN edge | Assets, HTML, API responses | Edge datacenters | Mirrors Cache-Control | Purge API, TTL |
Laravel's framework caches: a different animal
Laravel adds a family of caches that confuse people because they have nothing to do with content. They cache the application's own configuration and are rebuilt at deploy time, not at runtime:
php artisan config:cache # merges all config files + .env into one cached file
php artisan route:cache # compiles the route table
php artisan view:cache # precompiles Blade templates
These caches make framework boot meaningfully faster, and config:cache has a famous side effect: once it runs, env() calls outside config files return null, because the .env file is no longer read at runtime. If your site behaves differently in production than locally, a stale or missing config cache is suspect number one.
UnfoldCMS runs on Laravel 12, so it gets this entire toolkit — framework caches, the Cache facade, every driver from plain files on shared hosting up to Redis — as standard Laravel machinery rather than a custom caching system you have to learn separately. The same artisan commands you'd use on any Laravel app apply unchanged.
Cache invalidation, or why "clear the cache" fixes everything
There's an old joke that there are only two hard things in computer science: cache invalidation and naming things. The first half is why every CMS support thread starts with "clear your cache."
The problem is structural. When you edit a post, the CMS knows that post changed — but the old content might live in the object cache (the post query), the page cache (the post's URL, plus the homepage, plus the category page, plus the RSS feed), the CDN (all of those URLs, in 40 datacenters), and some visitor's browser. No single system has the full list. So when something looks stale, the practical fix is to flush everything, because finding the one stale entry costs more than rebuilding all of them.
Good cache design shrinks this problem in two ways:
- Short TTLs as a safety net. If every cached page expires within 5 minutes, the worst-case staleness is 5 minutes. You trade a little performance for the guarantee that wrong content fixes itself.
- Event-based invalidation for what matters. On publish or update, the CMS actively deletes the affected keys —
Cache::forget('post.' . $slug)— and calls the CDN purge API for the affected URLs. Outgoing webhooks extend the same idea beyond your own stack: UnfoldCMS fires HMAC-signed webhooks on content events, so a publish can purge a CDN or trigger a static rebuild without anyone touching a "clear cache" button.
Scheduled publishing adds a subtle case: a post set to go live at 9:00 needs the homepage cache to expire at 9:00, not whenever its TTL happens to lapse. Either keep list-page TTLs short, or fire the invalidation event when the scheduler publishes.
What to cache, and for how long
Practical defaults that hold up for most content sites:
- Settings, menus, category trees: object-cache for 1 hour, with event-based clearing on save. Read on every page, edited a few times a month.
- Rendered pages (anonymous traffic): 5–15 minutes for a site updated daily; up to an hour for a site updated weekly. Pair with publish-time purging if your CDN supports it.
- API list endpoints: 1–5 minutes at the CDN. Short enough that nobody notices staleness, long enough to absorb traffic spikes.
- Images and fingerprinted assets: 1 year,
immutable. This includes generated variants — automatic WebP conversions, for example, are derived files that never change once written, ideal for long cache lifetimes. - Search results, logged-in pages, form pages: don't page-cache. Object-cache the expensive pieces inside them instead.
When in doubt, start with shorter TTLs and lengthen them once you trust your invalidation. A cache that's too short costs milliseconds; a cache that's too long costs a support ticket.
Three pitfalls that bite almost everyone
Caching a logged-in page
Page-cache an authenticated response once, and the next anonymous visitor sees the admin bar — or worse, the admin's account page. Every page cache needs a hard bypass for requests with a session or auth cookie. Test it explicitly: log in, load a page, log out, load it again. If you ever see logged-in chrome while logged out, the bypass is broken.
Stale CSRF tokens on cached forms
A CSRF token embedded in a cached HTML form belongs to a session that expired hours ago. Visitors fill in the form and get a 419 error on submit. Fixes, in order of preference: exclude pages with forms from the page cache, load the token via a small uncached fetch after page load, or keep the page TTL shorter than the session lifetime. If your contact form "randomly" fails for some users, this is almost always why.
Forgetting to clear after deploy
You deploy new code and the site half-updates: new PHP behavior, old config; new Blade markup, old compiled views; or — with validate_timestamps=0 — old code entirely, because OPcache never noticed the files changed. The cure is making cache-clearing part of the deploy script, not a thing a human remembers:
php artisan config:cache && php artisan route:cache && php artisan view:cache
sudo systemctl reload php8.3-fpm # resets OPcache
Rebuild rather than just clear — config:cache is both the flush and the warm-up in one step.
Where this leaves you
Caching is the difference between a CMS that needs a bigger server every year and one that runs a busy blog on a $5 VPS. Layer it deliberately: OPcache always on, object cache for queries that repeat, page or CDN cache for anonymous HTML with short TTLs, year-long browser caching for fingerprinted assets — and invalidation wired into publish events and deploys so "clear the cache" becomes a thing you automate, not a thing you say.
Speed is also an SEO input: Core Web Vitals reward exactly the TTFB and asset-caching wins described here. If you're tuning a site for search, caching belongs on the same list as the items in our CMS SEO checklist — and if you'd rather sidestep origin caching entirely, a static frontend on a CDN (see deploying an UnfoldCMS site on Netlify) is the logical extreme of the same idea.
Frequently Asked Questions
Why do my CMS changes not show up immediately after publishing? A cached version of the page is being served. The fix is to clear the relevant cache layer — usually the page cache or object cache. Most CMSes do this automatically on publish, but misconfigured TTLs or CDN edge caches can still serve stale content.
What is the difference between page cache and object cache? Page cache stores the complete HTML output of a URL. Object cache stores individual data items (database query results, computed values) that multiple pages use. Page cache is faster; object cache is more granular and survives layout changes.
How long should I cache a CMS page? Static pages (About, Pricing): 24 hours or more. Blog posts after publishing: 1–6 hours. The homepage and any page with real-time data (stock, live scores): no page cache, or 1–5 minutes max.
Does Cloudflare caching replace server-side caching? No — they work at different layers. Cloudflare CDN caches at the edge (close to the visitor). Server-side caching (Redis, Laravel cache) reduces database load at the origin. You want both: server-side reduces origin cost, CDN reduces latency.
What is cache stampede and how do I prevent it? Cache stampede happens when a cached item expires and hundreds of simultaneous requests all try to regenerate it at once, hammering the database. Prevent it with a lock (only one process rebuilds) or cache warming (pre-generate the cache before expiry).
Free & Open Source
Own your CMS. No subscriptions.
Unfold CMS is free to download and self-host. Built on Laravel + React, full source code included.
Share this post: