Nearly every speed improvement on the web is some form of "do not do that work again." Caching is the practice of keeping a copy of something expensive so the next request can be served from the copy. The expensive thing might be a database query, a rendered page, a file crossing an ocean, or a few kilobytes of CSS that the browser already downloaded yesterday.
The trick is knowing where the copies live, because when a page refuses to update, one of those layers is the culprit. A typical request passes through four or five places that may each hold their own copy, with their own idea of how long it stays fresh. Nobody planned it that way; each layer was added to solve a different problem, and they happen to stack.
This article walks through the layers from the visitor's browser down to the database, shows the headers that control them, covers the one reliable trick for long-lived files, and finishes with a method for tracking down a stale page without guessing. The short version of the method: test each layer separately, and trust what the response headers say over what you remember configuring.
The layers in one picture
Follow a single page view. The browser looks in its own cache first. If it has nothing fresh, the request goes out across the internet, perhaps to a CDN edge server close to the visitor. If the CDN has no copy, it asks your web server. The web server may hold a saved copy of the finished page. If it does not, it runs the application, which asks an object cache for data it has fetched before, and only if that fails does it query the database. Meanwhile the PHP engine itself is reading compiled code from an opcode cache rather than parsing the source files again.
The deeper layers are invisible to the visitor and only change how fast the answer is built. The upper three can change what the visitor sees, which is why they cause the confusing problems.
The browser cache
The closest layer to the visitor. When the server sends a file, it can include a Cache-Control header that says how long the browser may reuse it without asking:
Cache-Control: public, max-age=31536000, immutable
A logo that never changes might carry a year, as above; an HTML page might carry zero or a few minutes. While the copy is fresh the browser does not contact the server at all, which is why a change you made can be invisible to someone who visited recently.
When the copy has expired, the browser need not download it again. It can ask the server "has this changed?" using a validator, either an ETag or a last-modified date:
GET /style.css HTTP/2
If-None-Match: "5e1f-62d1a"
HTTP/2 304 Not Modified
A 304 reply has no body, so it costs a few hundred bytes instead of the whole file. That is a good deal, though it still needs a round trip, which on a slow mobile connection is the expensive part.
| Directive | What it tells caches | Typical use |
|---|---|---|
max-age=N | Fresh for N seconds | Images, fonts, versioned CSS and JS |
no-cache | May store it, but must revalidate before each use | HTML pages |
no-store | Do not keep a copy at all | Banking pages, account settings |
private | Only the visitor's own browser may keep it | Logged-in pages |
public | Shared caches such as a CDN may keep it too | Anything identical for everyone |
s-maxage=N | Like max-age, but for shared caches only | Letting a CDN cache longer than browsers |
The names are misleading. no-cache does not mean "never cache"; it means "check with me first". If you want nothing stored, the word is no-store.
Browsers also apply their own heuristics when a response has a last-modified date but no explicit lifetime: many will cache for roughly a tenth of the time since the file last changed. That is why a file with no cache headers still sometimes appears stuck. Set the headers on purpose rather than leaving it to guesswork.
The CDN
A content delivery network keeps copies of your files on servers around the world and serves visitors from the nearest one. For static files such as images, stylesheets and scripts, this is a big win: a visitor in Sydney fetching from a server in Frankfurt waits for the speed of light, and a nearby edge removes most of that wait. Many CDNs can cache whole HTML pages too, which is a bigger win and a bigger risk, because personalised content such as a shopping cart must not be cached for everyone.
A CDN adds a layer with its own rules. It usually respects your Cache-Control headers, but most also have dashboard settings that override them, such as a minimum edge lifetime or "cache everything". When the two disagree, the dashboard often wins, and that is a frequent source of "but I set it to five minutes" confusion. The companion piece on CDN limits covers where a CDN helps and where it gets in the way.
You can usually tell a CDN served the response from its headers:
curl -sI https://example.com/ | grep -iE 'age|cache|x-cache|server'
age: 1432
cf-cache-status: HIT
Header names vary by provider. A positive Age means a shared cache has held this copy for that many seconds. A HIT status means the edge answered; a MISS means it had to ask your server; and BYPASS or DYNAMIC means it deliberately did not cache.
The server-side page cache
For something like WordPress, generating a page means running PHP and querying a database, often dozens of times. A page cache saves the finished HTML and serves it directly the next time, skipping all that. This single change often cuts load times from a second or more to a few dozen milliseconds, and it lets a small server cope with a traffic spike that would otherwise flatten it.
On shared hosting the page cache is usually a WordPress plugin, a server feature such as LiteSpeed's cache, or a panel switch. On a VPS you might use nginx's fastcgi_cache or a reverse proxy such as Varnish in front. In every case the questions are the same: which pages are cached, for whom, and how does the cache learn that something changed?
That last question is where trouble starts. A good page cache is purged when you publish or edit a post, and it knows not to cache requests that carry a login cookie, a cart cookie, or a query string such as ?s=search. A poor one serves the same version to everyone for hours. Check that your own logged-in view is not cached and that a logged-out visitor sees your latest edit within a minute.
Object and opcode caches
Deeper down, tools such as Redis or Memcached store the results of database queries and other computed values, so the application can skip asking the database the same question repeatedly. A WordPress site with a persistent object cache typically makes far fewer queries per page on dynamic requests, such as the admin area, search or a logged-in shop, which are exactly the requests a page cache cannot help.
PHP's opcode cache (OPcache, built into modern PHP 8.x) stores compiled code so files are not read and parsed again on every request. It is normally on by default on decent hosting. Its one quirk is that when it is set not to check for modified files, a deployment needs a reset before the new code takes effect; on shared hosting, the host handles the timing.
These layers do not show up in the browser, but they raise the ceiling on how much traffic one server can handle. Think of them as capacity features, whereas the first three are mostly latency features. When people say "the site is fast but falls over at 200 concurrent users", the missing piece is usually here.
What you can control depends on the plan. On shared hosting you generally get the browser headers (through .htaccess or the panel), a page cache switch, and sometimes Redis as an add-on, while OPcache is the host's business. On a VPS you configure every layer yourself and are responsible for sizing them, for example giving Redis a memory limit so it does not compete with the database. Managed platforms usually bundle a page cache and CDN and give you a single purge button, which is convenient until you need to know which layer answered.
| Layer | Who sees the copy | Cleared by | Risk if stale |
|---|---|---|---|
| Browser | One visitor | Hard refresh, private window | Visitor sees old files |
| CDN | Everyone in a region | Purge in dashboard or API | Many visitors see old pages |
| Page cache | All visitors | Plugin or panel purge | Everyone sees old pages |
| Object cache | The application | Flush Redis or Memcached | Wrong data, odd settings |
| Opcode cache | PHP itself | Reload PHP, or wait | Old code runs after deploy |
Cache busting
The standard trick for long-lived files is to put a version or hash in the filename: style.4f2a9c.css instead of style.css. When the file changes, the name changes, so every cache sees it as a brand-new file. You can then safely tell every cache to keep the old name forever.
The same effect comes from a query string, style.css?v=4f2a9c. Most browsers and CDNs treat that as a separate URL, though a few proxies ignore query strings when caching, and a changed file name is the more dependable form. WordPress adds a version query string to enqueued scripts and styles automatically, tied to the theme or plugin version, which is why updating a theme often refreshes the look for everyone.
HTML is the exception. The page that references style.7b3e10.css has to be fresh, or it will keep pointing at the old name. So the usual recipe is a short or revalidated lifetime for HTML, and a year for the files it names.
When a page will not update
Work from the visitor outward, or from the server inward, and test each layer. Do not change three things at once; that is how you lose track of which one worked.
- Open the page in a private window. If it is correct there, the problem is your browser's copy. A hard refresh (Ctrl+Shift+R) usually clears it for the page, not always for its stylesheets.
- Try a different network, or your phone on mobile data. If the page is stale only on your normal connection, a proxy or ISP cache may be involved, which is rare nowadays.
- Look at the response headers with
curl -sI https://example.com/page/. CheckAge,X-CacheorCF-Cache-Status,Cache-ControlandLast-Modified. A largeAgetells you a shared cache holds the page. - Bypass the CDN: request the origin directly, for example with
curl --resolve example.com:443:203.0.113.10 https://example.com/page/, which sends the request to your server's address while keeping the host name. If the origin is right and the public URL is wrong, the CDN has the old copy. Purge it. - Clear the page cache in your plugin or panel. Then check the origin again.
- If the content is data-driven and still wrong, flush the object cache, and as a last step restart or reload PHP.
Nine times in ten the answer is that one of these layers was told to keep the file for longer than you realised. The tenth time, two layers are cached on top of each other and you purged only one. If the problem keeps coming back, the troubleshooting guide has a wider checklist.
Mistakes that cost the most
A few errors cause a disproportionate share of cache incidents.
- Caching a page that includes a person's name, basket or account details, and serving it to the next visitor. This is a privacy incident, not a performance bug.
- Giving HTML a long lifetime on a site that has no cache purge, so corrections take days to reach readers.
- Setting a long lifetime on files that have no version in the name, such as
/css/main.css, and then changing them. - Forgetting that error responses can be cached. A 500 or a maintenance page served briefly can stick for minutes at the edge.
- Stacking two page-cache plugins, or a plugin plus a server cache, with different purge rules. They hide each other's bugs.
- Caching redirects. A 301 is cached by browsers for a long time, sometimes indefinitely, so test a redirect with a 302 first and switch once you are sure. The redirect generator produces both forms.
The page weight tool helps you see how much a page asks the browser to download, which tells you how much a good browser cache policy could save on repeat visits.