# Redis page caching for WordPress: why disk caches fall over

Almost every WordPress caching plugin you have heard of stores its cache the same way: as files on disk. A directory tree full of HTML files, one per URL, plus rewrite rules or a bit of PHP to serve them. The alternatives are rarer and come with their own catch. A few plugins keep cached pages in the database, which puts the load back on the one component a page cache exists to protect. Others hand the job to the web server's own cache, which is fast but blunt: it knows URLs and headers, not content, so it cannot tell which pages a post appears on without the plugin telling it.

The file on disk is as old as the web, and for a small brochure site on one server it works. But disk is a quietly limiting choice, and the limits show up exactly where caching starts to matter: invalidation, variants, concurrency, and the moment a site outgrows one server. MilliCache made the opposite choice from day one. The page cache lives in Redis, in memory, as structured data instead of files.

This post is the architectural argument. Not "Redis is faster", though it is, but what a page cache can do when its storage engine has sets, hashes, transactions and expiry built in, and what it cannot do when its storage engine is a filesystem.

## What a disk cache actually is

A file-based page cache maps each URL to a path like `wp-content/cache/example.com/blog/my-post/index.html`. Serving works one of two ways:

- **Web server rewrite.** Apache or nginx checks whether the file exists and serves it directly. Very fast, but the web server is a blunt instrument: expressing "unless this cookie is present, and pick the right variant for this device, and only if it has not expired" in rewrite rules ranges from painful to impossible.
- **PHP serve.** PHP starts, the plugin maps the request to a file, reads it and prints it. More flexible, but you pay PHP's start-up cost on every "cached" hit.

As storage, the filesystem gives you exactly one thing: a named blob. Everything else a cache needs has to be built on top of that. And that is where the trouble starts.

## Where the filesystem model breaks

**Invalidation has no index.** The filesystem does not know what is inside the files. "Clear everything that shows this post" means either knowing every URL the post appears on (you don't: archives, feeds, paginated pages, query-string variants) or wiping the whole cache directory. That is why file-based plugins fall back to a full wipe as soon as the relationships get complicated, and why full flushes became normal. We wrote about [what a full flush costs](/blog/stop-flushing-your-entire-wordpress-cache/); the storage model is a big part of why plugins do it. The wipe itself is quick (3,000 cached pages went in 0.6 seconds on a laptop SSD in our test); the cost is the rebuild afterwards.

**Variants barely fit.** One URL with several correct responses (mobile and desktop, a currency cookie, `Accept: text/markdown` for an AI agent) has to be encoded into file names, and every part of the serving path has to agree on the encoding. File caches support a few fixed variants, typically mobile and WebP, and bypass everything else.

**Expiry needs a janitor.** Files do not expire. Something has to run a sweep, usually on WP-Cron, that checks every file's age and deletes the stale ones. Between sweeps, stale files accumulate; if cron stalls, and WP-Cron stalls easily, the cache directory grows until something else breaks.

**Concurrency is your problem.** Two processes writing the same cache file, a purge racing a write, a reader catching a half-written file: the filesystem referees none of it. Plugins work around it with temporary files and renames, and every workaround is more code on the hot path.

**A second web server ends the party.** The cache lives on one machine's disk. Put a second server behind a load balancer and each one has its own private, disagreeing cache: purge one and the other keeps serving the old page. The classic workaround, a shared network mount, replaces a fast local disk with a slow remote one plus locking bugs.

None of these are bugs in any particular plugin. They are properties of the storage model.

## What changes with Redis

Redis (or Valkey, KeyDB, Dragonfly, a managed service like ElastiCache; they speak the same protocol and MilliCache works with all of them) is an in-memory data store with real data structures. Each workaround above maps to something Redis simply has:

- **Expiry is built in.** Every cache entry carries its own lifetime; the server removes it on time. No sweep, no janitor.
- **Memory has a ceiling.** Set `maxmemory` and `allkeys-lru`, and the cache stays inside its budget by dropping the least recently used pages first. A disk cache has no equivalent; it grows until something else breaks.
- **Sets make invalidation a lookup.** MilliCache tags every entry with flags (`post:123`, `archive:category:5`, and with the MilliCache Pro Block Editor module `pattern:134` for a synced pattern), and each flag is a Redis set of the entries that carry it. "Clear every page that renders synced pattern 134" reads one set and deletes its members, however many pages that pattern was dropped into. The cost grows with the pages affected, not with the size of the cache.
- **Writes are transactions.** Storing a page, adding it to its flag sets and setting its lifetime happen as one Redis transaction. No half-written entries, no purge racing a store.
- **It is a server.** Every web node talks to the same cache. Purge once, purged everywhere. Scaling out stops being a caching problem at all.

Because the cache is structured data rather than opaque files, MilliCache can also be smarter about what it stores. Bodies are stored by content: the HTML is kept once under a hash of itself, and entries point at it. When pages 2 to 40 of a paginated archive render identical output, or twenty query-string variants of a landing page produce the same HTML, the bytes exist in memory once. Gzip on top shrinks what is left several-fold; the Status tab shows the result as "saved storage", and on the sites we run it is usually around 80 percent.

![MilliCache Pro status cards for a multisite network: 1,340 entries for 884 pages, 39.23 MB cache size at 30 KB per entry, and 80 percent saved storage, 160.59 MB](https://www.millipress.com/content/uploads/2026/09/millicache-pro-metrics-edited.png)Deduplication and gzip compression save 80 percent of memory in this case.Here is the invalidation point measured, on a WooCommerce test shop with 3,000 cached entries:

 terminal 

 ```
 1$ wp millicache clear --id=550 --related
 2Success: Cleared 2000 cache entries.   # the product plus its archives, author, home and feed, ~0.25 s
 3
 4$ wp millicache clear --expire
 5Success: Expired 1000 cache entries.   # marked stale, still served, ~0.25 s
 6
 7$ wp millicache clear
 8Success: Cleared 1000 cache entries.   # deleted, ~0.1 s

```

Two thousand entries went in a quarter of a second without a single page being read, because the sets already knew which entries carried the product's flag. What `--related` clears is the set of pages that show a post by default: the post itself, the archives and taxonomy pages that list it, its author page, the home page and the feed. A page that shows the product some other way, through a synced pattern, a related-products widget or a custom query, is not in that set unless it carries a flag that says so, and that is what custom flags are for: a rule or a filter can tag any page with any label, and the same clear then reaches exactly the pages you defined. A file cache in the same situation has two options: wipe all 3,000, or guess.

## The life of a request

Here is the whole path, because the serving side matters as much as the storage side:

1. WordPress loads `advanced-cache.php` at the very start of its bootstrap, before plugins, themes or the database.
2. MilliCache evaluates its bootstrap rules (method, cookies, excluded paths, your own rules) in plain PHP, in microseconds, with no WordPress loaded.
3. It builds the cache key from the URL, the cookies you have not ignored and any buckets your rules set, and does one Redis lookup.
4. **HIT**: the stored headers are sent, the body is streamed out, and the request ends. WordPress never loads. On our test sites this takes between 4 and 17 milliseconds.
5. **MISS**: WordPress renders as usual; the response is stored with its flags on the way out, and the next visitor gets the fast path.

There is a third outcome worth knowing: **STALE**. An entry whose lifetime has run out is not thrown away; for as long as its grace period lasts, the old copy goes out instantly and a fresh one is built behind it. Nobody waits for a render.

## "But now I have to run Redis"

The honest objection, and three honest answers.

First, you may already be running it. Managed WordPress hosts increasingly provide Redis for object caching, and every major cloud has a managed offering (AWS ElastiCache, Google Memorystore, DigitalOcean, Upstash). All of them work with MilliCache, including over TLS or through a Unix socket.

Second, the setup is small and boring, in the good sense:

 redis.conf 

 ```
 1# A page cache needs very little
 2maxmemory 256mb
 3maxmemory-policy allkeys-lru
 4save ""            # cache data is regenerable; persistence off
 5appendonly no

```

A cache can always be rebuilt from WordPress, so you can skip persistence. For sizing: the multisite network in [our hit-rate post](/blog/wordpress-cache-hit-rate/) holds 1,340 entries in 39 MB, so 256 MB goes a long way, and content addressing plus gzip stretch it further than page counts suggest.

Third, checking that it works is built in:

 terminal 

 ```
 1wp millicache test     # connection, ping, write, read, delete: all should PASS
 2wp millicache status   # drop-in, connection, entry count, cache size
 3wp millicache cli      # a redis-cli session on exactly the server MilliCache uses

```

## When you outgrow one of anything

The same architecture keeps working as the infrastructure grows. MilliCache, the free plugin, supports master and replica replication and Sentinel failover through a single constant:

 wp-config.php 

 ```
 1define( 'MC_STORAGE_HOST', array(
 2	'master'   => 'master.example.com',                             // writes
 3	'replicas' => array( '127.0.0.1', 'replica.example.com:6380' ), // reads
 4) );

```

Writes go to the master, reads can come from a replica on the same machine as the web server, and with Sentinel the master is rediscovered automatically after a failover. Try expressing any of that with a directory of HTML files.

## Where disk is still fine

A small site, one server, mostly static content, no Redis on a budget host: a file cache behind nginx rewrites will serve pages fast, and that may be all you need. The filesystem's problems are scale problems: how often you invalidate, how many variants you serve, how big a purge is, how many servers you run. If none of those numbers is growing, you will not feel the ceiling.

But if you are reading a post about cache architecture, one of your numbers probably is.

## Try it

**MilliCache** is the full Redis page cache described here: the `advanced-cache.php` fast path, flag-based invalidation, buckets, grace, gzip, content addressing, and replication and Sentinel support. Start with the [installation guide](/docs/millicache/01-getting-started/20-installation) and the [storage backends overview](/docs/millicache/08-storage-backends/01-overview) for the Redis, Valkey, KeyDB and Dragonfly specifics.

**MilliCache Pro** builds on the same connection: the [Object Cache module](/docs/millicache-pro/02-modules/09-object-cache) adds a persistent object cache that reuses the page cache's Redis to speed up wp-admin, logged-in users and cache misses, the [Storage Connections](/docs/millicache-pro/02-modules/10-storage-connections) screen configures replication and Sentinel without touching `wp-config.php`, and [Detailed Metrics](/docs/millicache-pro/02-modules/06-detailed-metrics) shows hit rate, memory and response times, so you can watch the architecture pay off.

[ Compare MilliCache and MilliCache Pro ](/millicache-pro/#compare)