A publisher’s WordPress workload has a shape that almost nothing else has: something like 99% of requests are anonymous readers asking for the same handful of article URLs, and the remaining sliver is a few dozen authenticated editors whose writes have to appear immediately. That asymmetry is the whole architecture. Serve the anonymous side entirely from a full-page cache at the edge so PHP never runs, keep a hard cookie boundary so an anonymous response can never carry a session, and purge by surrogate key on publish instead of flushing. Everything else in the high-traffic WordPress stack — object cache, database shape, media offload — exists to make the small authenticated side and the cache-miss path survivable.
What you’ll accomplish
- Confirm your anonymous responses are actually cacheable at the edge, with a
cache-controland a risingagethat prove it. - Prove no
Set-Cookieleaks onto an anonymous hit, which is the single most common cause of a collapsed hit ratio. - Design a cache key that varies on the things that matter and ignores tracking query strings.
- Replace full-flush-on-publish with a targeted purge that hits the post, home, archives, feeds and sitemap.
- Measure the editorial side separately: autoloaded option size,
wp_postsandwp_postmetagrowth, revision accumulation.
Prerequisites
- WordPress 6.0 or newer with WP-CLI 2.8+ available on the origin (
wp --infoto confirm). - Shell access to the origin, and the ability to read response headers from outside your network.
- An edge tier in front of the origin: a CDN, a Varnish layer, or Nginx
fastcgi_cache. The header names differ (cf-cache-status,x-cache,x-fastcgi-cache) but the reasoning is identical. - Surrogate-key or cache-tag purging requires support at that tier. Fastly exposes
Surrogate-Key, Varnish needs thexkeyVMOD compiled in, and Cloudflare’sCache-Tagis an Enterprise-plan feature. If you have none of those, you can still purge by URL list — it’s just more work per publish. - A persistent object cache (Redis or Memcached) with a WordPress drop-in installed. Without it, the editor path has no floor.
This page assumes the platform underneath already supplies those pieces. Proving that a candidate host actually does is a separate exercise, set out in enterprise WordPress hosting requirements.
How it works
A publisher request splits at the edge on exactly one signal: the presence of a wordpress_logged_in_* cookie. Everything without it is an anonymous read and should be answered from cache without the origin ever hearing about it. Everything with it bypasses the cache entirely and pays full price at the origin.
This is why a publisher is a much better caching candidate than a store. On an e-commerce site the cart poisons the whole page — a per-visitor fragment sits inside otherwise-static HTML, and the moment WooCommerce sets woocommerce_items_in_cart the response stops being shareable. An article page has no per-visitor state at all. If yours does, that state is a bug in the theme, not a fact about the workload.
Two things break this in practice, and both are silent:
A stray Set-Cookie on an anonymous response. A plugin starting a PHP session, an A/B testing snippet, an analytics helper, a “recently viewed” widget — any of them will attach a cookie to a response that should have been shared by a million readers. Most edge tiers refuse to store a response carrying Set-Cookie, so the hit ratio quietly falls and the origin starts absorbing read traffic.
Query strings in the cache key. Every share of an article arrives with a different ?utm_source= value. If those are part of the key, one article becomes thousands of distinct cache objects, each cold. Strip tracking parameters at the edge before the key is computed, or configure the tier to ignore all query strings on article paths.
What should vary is short: device class if you serve genuinely different markup to phones, country only if you actually geo-target content, and any alternate rendering such as AMP. Each dimension multiplies your object count, so add one only when you can name the content difference it produces.
Cache key design and the response headers that carry it
For an anonymous article the response you want looks roughly like this:
cache-control: public, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400
vary: accept-encoding
s-maxage governs the shared cache only, so the edge holds the page for five minutes while browsers are free to have a shorter private TTL. The two stale-* directives (RFC 5861) are the ones publishers underuse. stale-while-revalidate=60 lets the edge serve the slightly-old copy instantly and refresh it in the background, so a TTL expiry never turns into a thundering herd on the origin. stale-if-error=86400 means that if the origin returns a 5xx or times out — during a traffic spike, a bad deploy, a database failover — readers keep getting yesterday’s article instead of an error page. For a news site, stale is almost always better than broken.
Keep vary minimal. vary: cookie on an anonymous route is the same mistake as leaking Set-Cookie: it fragments the cache per visitor.
Purge strategy: one publish, many surfaces
Publishing a post does not invalidate one page. It invalidates every cached surface that renders a list containing that post.
The tempting shortcut is a full flush. Resist it. On a site with a hundred thousand cached objects, a flush at 09:00 on a Monday drops your hit ratio to zero and sends every reader to PHP at once — you have converted a routine publish into a self-inflicted spike. Tag the response instead: emit a Surrogate-Key (or Cache-Tag) header listing the post ID, each term ID, the author ID and a home key, then purge only those keys on save_post. Everything else stays warm.
Step-by-step
- Confirm the anonymous response is cacheable. Ask for an article the way a reader would, with no cookies.
curl -sI https://www.example.com/2026/09/story-slug/ | grep -iE 'cache-control|age|x-cache|cf-cache-status|vary'
Expected on a healthy publisher stack:
cache-control: public, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400
age: 137
cf-cache-status: HIT
vary: Accept-Encoding
A rising age across repeated calls is the proof — it means the edge is serving its own copy, not proxying to you.
- Prove no session cookie is attached. Same request, looking only for the thing that must be absent.
curl -sI https://www.example.com/2026/09/story-slug/ | grep -i 'set-cookie'
Expected output: nothing at all. Empty output is the pass condition. Any line here — PHPSESSID, a testing cookie, a “viewed posts” cookie — is a defect to fix before tuning anything else.
- Check that tracking parameters don’t fragment the key. Request the same article twice with different campaign strings.
curl -sI 'https://www.example.com/2026/09/story-slug/?utm_source=newsletter' | grep -iE 'cf-cache-status|^age:'
curl -sI 'https://www.example.com/2026/09/story-slug/?utm_source=twitter' | grep -iE 'cf-cache-status|^age:'
Expected — both report HIT, with a similar age:
cf-cache-status: HIT
age: 141
cf-cache-status: HIT
age: 143
If the second pair says MISS with age: 0, the query string is in your cache key and every share is buying you a cold object.
- Verify the logged-in path bypasses. Send the cookie an editor would carry.
curl -sI -H 'Cookie: wordpress_logged_in_abc123=placeholder' https://www.example.com/2026/09/story-slug/ | grep -iE 'cache-control|cf-cache-status'
Expected:
cache-control: no-cache, must-revalidate, max-age=0
cf-cache-status: BYPASS
- Measure autoloaded option weight on the origin. This is the tax every single uncached request pays before WordPress does anything useful.
wp db query "SELECT ROUND(SUM(LENGTH(option_value))/1024,1) AS autoload_kb, COUNT(*) AS rows_n FROM wp_options WHERE autoload IN ('yes','on','auto-on','auto');"
Expected on a healthy site:
+-------------+--------+
| autoload_kb | rows_n |
+-------------+--------+
| 412.6 | 287 |
+-------------+--------+
Under 800 KB is fine. Past a couple of megabytes an editor feels it on every admin screen. Find the offenders and work through the autoload audit.
- Check table growth. Editorial archives grow forever, and
wp_postmetagrows faster thanwp_posts.
wp db size --tables --human-readable
Expected shape on a mid-size publisher:
+-----------------------+---------+
| Name | Size |
+-----------------------+---------+
| wp_posts | 890 MB |
| wp_postmeta | 3.1 GB |
| wp_options | 42 MB |
| wp_term_relationships | 61 MB |
+-----------------------+---------+
- Count revisions. They live in
wp_postsand they are usually the largest avoidable share of it.
wp post list --post_type=revision --format=count
Expected on a site that has never pruned:
184203
Cap them going forward with define( 'WP_POST_REVISIONS', 5 ); in wp-config.php, then delete the backlog in batches during a quiet window.
- Purge and confirm the fan-out. After publishing, check each affected surface returns fresh content rather than the pre-publish copy.
for u in / /category/politics/ /tag/election/ /author/jsmith/ /feed/; do
printf '%-24s ' "$u"
curl -sI "https://www.example.com$u" | grep -i '^age:'
done
Expected immediately after a correct purge:
/ age: 2
/category/politics/ age: 1
/tag/election/ age: 3
/author/jsmith/ age: 0
/feed/ age: 1
Every age should be near zero. Any surface still reporting a large age was not in your purge set and is serving a stale index.
Validate it works
Run these together, in order, against production:
curl -sI https://www.example.com/ | grep -icE '^set-cookie'
curl -sI https://www.example.com/2026/09/story-slug/ | grep -i '^age:'
wp eval 'var_dump( wp_using_ext_object_cache() );'
Pass conditions: the first returns 0, the second returns a non-zero age, and the third prints bool(true). Then publish a trivial edit to a live post and re-run step 8 — if all the archive surfaces drop to a low age within a few seconds and the rest of the site keeps its age climbing, your purge is targeted rather than a flush.
The editorial side is a different system
Editors bypass the page cache by definition, so none of the work above helps them. Their experience is decided by four things: the persistent object cache (Redis or Memcached, so repeated metadata lookups stay in memory), autoloaded option size, the Heartbeat API’s polling interval (adjustable through the heartbeat_settings filter — lengthening it in the editor reduces a constant admin-ajax.php drip from every open tab), and how much wp_postmeta a single post drags in. Treat wp-admin as its own performance budget with its own measurements, not as a side effect of the reader-facing stack. The layered cache breakdown covers which layer serves which audience.
At real archive scale, WordPress core has no native read-replica support — routing reads to a replica means a database drop-in such as HyperDB or LudicrousDB, and it does not solve write-after-read staleness for editors who expect to see their own save. Site search is the clearer win: LIKE '%term%' against a multi-gigabyte wp_posts cannot use an index, so offload search to a real search engine and leave MySQL to serve primary keys. Media follows the same logic — uploads belong in object storage behind an image CDN that handles resizing, because an origin that resizes on request will fall over precisely when a story goes viral and every social preview asks for a new size at once.
Troubleshooting
cf-cache-status: DYNAMIC on every article. The edge decided the route is uncacheable before it ever looked at your headers. Usually a page rule or cache rule that only caches static extensions. Add an explicit rule that caches HTML on article paths, then re-run step 1.
age always returns 0 even though the status says HIT. Two edge nodes are answering you and neither has warmed, or the object’s TTL is shorter than the gap between your calls. Check s-maxage first; a s-maxage=1 left over from a debugging session behaves exactly like no cache at all.
Hit ratio fine on articles, terrible overall. Look at what else is on the domain. Preview URLs, admin-ajax.php, the REST API and search result pages are all correctly uncacheable, and if they are a large share of requests the aggregate number will look bad while the reader experience is fine. Segment the ratio by path before acting on it.
Editors report seeing stale content after publishing. Your purge fired but the browser is holding its own copy. Check that the anonymous response does not also carry a long max-age — s-maxage for the edge and a short max-age for browsers is the pairing you want, because you can purge the edge and you cannot purge a reader’s browser.
The origin falls over during a spike even though the cache is working. Almost always the image pipeline, not PHP. Look for the origin generating intermediate sizes on request, or a social crawler hitting an uncached size variant. Pre-generate sizes at upload and let the image CDN handle anything else.
When this is the wrong approach
If your traffic is mostly logged-in — a membership site, a forum, a learning platform — the edge cache has almost nothing to hold and this architecture buys you little. Invest in the object cache, PHP-FPM sizing, and the database instead. If personalisation genuinely varies the article body per reader, either move that personalisation to client-side JavaScript fetching a small cacheable endpoint, or accept that full-page caching is off the table. And if you publish a handful of posts a month to a few thousand readers, a single well-tuned origin with an object cache and a plain CDN is the right answer; surrogate keys and stale-if-error are operational complexity you would be carrying for nothing.
Running WordPress that earns real money deserves an operator — Stack Harbor runs that stack under Enterprise WordPress; book an infrastructure consult.