Host Talk / Caching, layer by layer

Caching, layer by layer

HOST TALK

10 min read · 2,200 words

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.

1. Browser cache saves the trip: no request at all 2. CDN edge saves distance: answered near the visitor 3. Server page cache saves PHP and database work for whole pages 4. Object cache (Redis) saves repeated queries inside the app 5. Opcode cache saves re-compiling PHP on every request request travels downwards until a layer has a fresh copy
Every layer is a chance to skip work, and a chance to serve something out of date.

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.

DirectiveWhat it tells cachesTypical use
max-age=NFresh for N secondsImages, fonts, versioned CSS and JS
no-cacheMay store it, but must revalidate before each useHTML pages
no-storeDo not keep a copy at allBanking pages, account settings
privateOnly the visitor's own browser may keep itLogged-in pages
publicShared caches such as a CDN may keep it tooAnything identical for everyone
s-maxage=NLike max-age, but for shared caches onlyLetting 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.

Illustrative server response time for the same page (not a measurement) No cache about 900 ms Page cache about 60 ms CDN hit about 30 ms for a nearby visitor Bar length is proportional to the example figures above.
Why page caching is the single biggest change for a database-driven site.

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.

A page cache can hide a slow site. If the front page loads in 60 ms from the cache but a first visit to a rarely requested page takes four seconds, real visitors from search engines are the ones who meet the four seconds. Test uncached pages as well.

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.

LayerWho sees the copyCleared byRisk if stale
BrowserOne visitorHard refresh, private windowVisitor sees old files
CDNEveryone in a regionPurge in dashboard or APIMany visitors see old pages
Page cacheAll visitorsPlugin or panel purgeEveryone sees old pages
Object cacheThe applicationFlush Redis or MemcachedWrong data, odd settings
Opcode cachePHP itselfReload PHP, or waitOld 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.

Same filename, file edited style.css (new) cache: style.css (old) never asked visitor sees the old file New filename, file edited style.7b3e10.css cache miss, fetch visitor gets the new file
A new name sidesteps every cache at once, which is why it beats trying to purge them one by one.

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.

  1. 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.
  2. 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.
  3. Look at the response headers with curl -sI https://example.com/page/. Check Age, X-Cache or CF-Cache-Status, Cache-Control and Last-Modified. A large Age tells you a shared cache holds the page.
  4. 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.
  5. Clear the page cache in your plugin or panel. Then check the origin again.
  6. 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.

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.

PreviousWhy email is the hardest part of hostingNextWhat a control panel actually does

More from Host Talk

Host Talk

Firewalls and WAFs: what they stop and what they do not

People use the word firewall for several different things, and mixing them up leads to false confidence....

Host Talk

Cron jobs and the strange case of WP-Cron

Cron is the scheduler built into Unix-like systems. You give it a command and a time pattern, and it runs the...

Host Talk

Why email is the hardest part of hosting

If you ask people who run websites for a living what gives them the most grief, many will say email. A...