WordPress builds every page dynamically: PHP executes, database queries run, and HTML is assembled from scratch on each request. That flexibility is the point of WordPress — but it does not scale on its own. A fast site at a hundred visitors a day can fall over at ten thousand.
The mistake most people make: caching for WordPress is not one thing. It is a stack of layers, each short-circuiting a different stage of the request. One cache plugin covers maybe two of them — which is why so many “optimized” sites stay slow.
The five layers, in request order
Each layer catches what the one above it missed:
- Browser cache — files the visitor already downloaded.
- CDN / edge cache — copies stored on servers near the visitor.
- Page cache — the finished HTML, served without running WordPress at all.
- Object cache — database results and computed objects, so WordPress runs cheaper when it must run.
- OPcache — compiled PHP bytecode, so the interpreter skips parsing on every request.
The database sits at the bottom; every layer exists to avoid reaching it.
Layer 1: OPcache — stop recompiling PHP
Every PHP request begins with the interpreter parsing .php files and compiling them into opcode. OPcache stores that compiled bytecode in shared memory, so compilation happens once per file instead of on every request. It ships with PHP as Zend OPcache — verify it is active with php -m | grep -i opcache.
A sensible production starting point:
opcache.enable=1opcache.memory_consumption=256opcache.interned_strings_buffer=16opcache.max_accelerated_files=10000opcache.validate_timestamps=0opcache.save_comments=1memory_consumption— shared memory in MB for compiled scripts. Ifopcache_get_status()shows near-zero free memory, raise it.max_accelerated_files— size of the script hash table. Keep it above your total.phpfile count (core + plugins + themes).validate_timestamps=0— OPcache never stats the filesystem for changed files. Deploys must reload PHP-FPM or callopcache_reset(), or visitors keep getting old code.save_comments=1— keeps doc comments in cached bytecode. WordPress core and many plugins read annotations at runtime; disabling this breaks things subtly.
OPcache is the cheapest performance win in the PHP stack — verify it is configured before tuning anything else.
Layer 2: Persistent object cache — stop re-querying the database
WordPress ships with an object cache (WP_Object_Cache in wp-includes/cache.php), but by default it is non-persistent: data lives in PHP memory only for the request and is discarded when it ends.
A persistent object cache changes that via a drop-in file, object-cache.php, in wp-content. When it exists, WordPress loads it early in bootstrap instead of the default in-memory cache, so objects survive across requests. The backend can be Redis, Memcached, APCu, or the filesystem — plugin code does not change, everything goes through wp_cache_get(), wp_cache_set(), wp_cache_delete(), and wp_cache_flush().
Transients are stored in the object cache when a persistent cache is active, but fall back to the wp_options table when it is not — so without one, heavy transient use bloats the autoloaded options on every request.
Redis vs. Memcached
Both work, and raw speed is roughly a tie — the win is going from no persistent object cache to having one:
- Redis (port 6379): rich data types, optional persistence to disk, replication, configurable eviction, 512 MB maximum value size.
- Memcached (port 11211): simple key-value, multithreaded, no persistence (a restart empties it), 1 MB default item limit.
Redis earns its recommendation on operational grounds: persistence avoids a cold-cache stampede after restarts, and replication gives a horizontal scaling path.
A widely used setup is the Redis Object Cache plugin by Till Krüss (slug redis-cache), which installs the drop-in for you. Connection details go in wp-config.php, above the “stop editing” line:
define( 'WP_REDIS_HOST', '127.0.0.1' );define( 'WP_REDIS_PORT', 6379 );define( 'WP_REDIS_DATABASE', 0 );define( 'WP_CACHE_KEY_SALT', 'example.com:' );WP_CACHE_KEY_SALT prefixes every cache key — essential on shared backends, where otherwise one site's wp_cache_flush() wipes every site on the instance. Use allkeys-lru as the Redis eviction policy (volatile-lru only evicts keys with a TTL, and much of WordPress's cached data has none), and monitor hit rate and evictions — a cache that evicts constantly is a slow database with extra steps.
Layer 3: Page cache — stop running WordPress at all
The page cache stores the finished HTML of a page and serves it directly, skipping PHP and MySQL entirely. For a content site with mostly anonymous visitors, this is the single biggest lever in the stack.
WordPress supports it through another drop-in: when WP_CACHE is true in wp-config.php, WordPress loads wp-content/advanced-cache.php before most of core initializes. Caching plugins (WP Rocket, W3 Total Cache, WP Super Cache, LiteSpeed Cache) provide that drop-in; server-level alternatives include Nginx FastCGI cache and Varnish.
This is also where the most dangerous mistakes happen, and the rule is unforgiving: never cache personalized content as a public page. Cart, checkout, account pages, logged-in views, previews, search results, and anything containing a nonce must be excluded via cookie checks or URL patterns. Serving one visitor's cart to another is a correctness and privacy failure, not a performance bug.
- Query strings: many setups treat each unique query string as a separate cache key, so marketing links (
utm_source,gclid,fbclid) land on uncached renders. Normalize or ignore known marketing parameters at the cache layer. - Invalidation: when a post updates, purge only the affected URLs (post, archives, homepage, feeds) — a full flush on every edit means the cache is warm for nobody. Good plugins hook
save_postfor targeted purges. - Nonces: WordPress nonces expire, so a page cached longer than the nonce lifetime serves dead forms. Keep page-cache TTLs short or load dynamic fragments via AJAX.
- One owner: a plugin page cache stacked on a host's server-level cache (or CDN HTML caching on top of both) means multiple purge systems and stale pages. Pick one layer to own full-page caching.
Layer 4: CDN and edge caching
A CDN keeps copies of your files on edge servers worldwide, so visitors far from your origin skip that latency on every asset. At minimum, serve images, CSS, JavaScript, and fonts from the edge with long lifetimes; many CDNs can also cache full HTML at the edge, moving the page cache out of your infrastructure.
Three things to get right: purge specific URLs when content changes (dashboard or API; some providers support cache tags to purge everything related to a piece of content at once); respect Cache-Control — fingerprint asset filenames (style.a3f9c1.css) so they can carry year-long max-age values, since a new filename is a new cache entry; and know what bypasses the edge — requests with a WordPress login cookie must skip HTML edge caching, or logged-in users see anonymous pages and vice versa.
Layer 5: Browser cache
The cheapest request is the one never made. Correct Cache-Control (max-age), Expires, and ETag headers let browsers reuse local copies of static files and revalidate with cheap 304 Not Modified responses instead of full downloads. Most caching plugins set sensible headers. Pair long max-age values with fingerprinted filenames so updates never stick behind a stale local copy.
Putting it together
When someone says “we added Redis and the site is still slow,” the usual explanation is that the request never got past the page cache — a page-cache hit means WordPress (and Redis) never ran, so the remaining slowness lives in the frontend. Measure before adding layers; each one only fixes the cost it actually controls. Baseline first, then re-measure after each layer: TTFB for cached vs. uncached requests, query count and time via the Query Monitor plugin, hit rates from Redis INFO stats, Memcached stats, and opcache_get_status(), and cache status headers (cf-cache-status, x-cache) to confirm which layer actually served the response. Warm the cache before measuring, and load-test on staging.
Common pitfalls
- Treating the cache as durable. Cached data must always be regenerable. If the cache is flushed, the site must keep working and rebuild it. Orders and settings live in the database; the cache is disposable.
- Cold-cache stampedes. Flushing everything — or restarting Memcached, which empties it — sends all concurrent requests to the database at once. Prefer targeted purges.
- Missing key salts on shared backends, so sites collide on keys and flush each other.
validate_timestamps=0without a deploy story — code changes silently not taking effect.- Caching
/wp-admin,wp-login.php, or editor endpoints. A cached admin-ajax response is a debugging nightmare. - Set-and-forget. Memory fills, eviction climbs, TTLs drift. Glance at hit rates monthly.
A strategy that scales
Add layers in order of return, and stop when measurement says the next one would not move anything:
- Every site: tune OPcache, set browser-cache headers, serve static assets via CDN.
- Growing content sites: add a page cache with targeted invalidation; normalize marketing query strings.
- Dynamic sites (WooCommerce, membership, LMS, busy wp-admin): add a persistent object cache with strict exclusions for cart, checkout, accounts, and logged-in traffic.
- High traffic and spikes: move full-page caching to the server (Nginx FastCGI cache, Varnish) or CDN edge; keep Redis for the dynamic remainder; treat hit rates as first-class metrics.
Further reading
- Cache — Advanced Administration Handbook (Developer.WordPress.org) — the official WordPress reference on object caching, opcode caches, and web-server caching.
- OPcache Runtime Configuration (PHP Manual) — the complete directive reference for tuning OPcache.
- How to Configure WordPress Object Cache (Cloudways) — a practical Redis vs. Memcached comparison with configuration steps.
- WordPress Caching Explained: Why One Cache Plugin Doesn't Fix Every Slow Site (The IT Guys) — a clear breakdown of what each layer can and cannot fix.
