The Case of the Stale Homepage: Debugging Three Layers of WordPress Caching

·


I deployed a change to my site, opened it in the browser, and saw the old version. curl showed the new version. My phone showed the old one. If you have ever run WordPress behind multiple caching layers, you know this particular flavor of confusion. This is the story of tracking one stale page through three separate caches, and the checklist I now use so it never takes that long again.

The setup: three caches deep

My stack serves a page through this chain: Cloudflare with Automatic Platform Optimization at the edge, an nginx srcache page cache backed by Redis at the origin, and the Redis object cache inside WordPress itself. Each layer is great at its job, and each layer is a separate place where an old copy of your HTML can hide.

Symptom: curl says fresh, browsers say stale

After a deploy that bumped my theme version, curl fetched HTML referencing the new asset version. Chrome kept loading the old one. The same URL returned different content depending on who asked. That asymmetry is the single most useful clue in a stale-cache hunt, because each layer varies on different parts of the request.

A plain curl sends no Accept-Encoding header and a generic user agent. A browser sends gzip and brotli support, a real user agent, and client hints. Caches key and filter on these. My srcache layer stored a gzip-compressed copy, so it only answered clients that accepted gzip. Plain curl bypassed it and hit the fresh origin, while anything browser-shaped got the stale gzip blob. Cloudflare APO adds another twist: it only engages its HTML edge cache for browser-like requests, so curl told me nothing about what the edge was serving.

Reproduce like a browser or do not bother

The debugging only started working when my test request looked like the failing client:

curl -s --compressed \
  -A "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36" \
  -D - -o /dev/null https://example.com/

Then read the response headers. Four of them identify which layer answered:

  • cf-cache-status: HIT means Cloudflare’s edge served it; DYNAMIC means it went to origin.
  • cf-apo-via: tcache means the APO tiered cache answered; origin means it passed through.
  • age: how many seconds old the served copy is. My “fresh” page had an age of 42005, almost twelve hours.
  • x-srcache-fetch-status: HIT means the origin’s Redis page cache answered without touching PHP.

With those headers, the mystery dissolved in minutes. Browsers were getting cf-cache-status: HIT with a twelve-hour age from APO’s tiered cache. My deploys reloaded nginx and reset OPcache but never told Cloudflare or the srcache layer that the HTML changed.

The fix: purge every layer, automatically

Manual purges work once and then you forget. The durable fix is making the deploy pipeline purge every cache layer as its final step. Mine now runs this over SSH after the new release goes live:

set -e
cd "$DEPLOY_PATH/current"
sudo -u www-data wp nginx-helper purge-all
read -r KEY EMAIL DOMAIN <<< "$(sudo -u www-data wp eval 'echo get_option("cloudflare_api_key")," ",get_option("cloudflare_api_email")," ",get_option("cloudflare_cached_domain_name");')"
ZONE=$(curl -s -H "X-Auth-Email: $EMAIL" -H "X-Auth-Key: $KEY" \
  "https://api.cloudflare.com/client/v4/zones?name=$DOMAIN" | jq -r '.result[0].id')
curl -sf -o /dev/null -X POST \
  -H "X-Auth-Email: $EMAIL" -H "X-Auth-Key: $KEY" \
  -H 'Content-Type: application/json' \
  --data '{"purge_everything":true}' \
  "https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache"

Two design choices worth copying. First, use the plugin-owned commands where they exist. wp nginx-helper purge-all respects whatever key prefix and Redis connection the plugin is configured with, while a hand-rolled redis-cli pattern delete silently breaks the day someone changes a setting in wp-admin. Second, read API credentials from where they already live instead of duplicating them into CI secrets, so there is exactly one place to rotate them.

The object cache will fool you too

One bonus trap from the same week: I updated a post’s content via WP-CLI and the site kept serving the old content even with the page caches purged. The Redis object cache still held the old post object. If you write to the database from anywhere unusual, follow it with wp cache flush or a targeted clean_post_cache call. When output looks stale and the page cache is provably clean, the object cache is the next suspect.

The checklist

  1. Reproduce with a browser-shaped request: real user agent plus –compressed.
  2. Read cf-cache-status, cf-apo-via, age, and your page cache’s status header.
  3. Identify which layer answered. The age header alone usually names the culprit.
  4. Purge that layer, re-request, confirm the age resets.
  5. Automate the purge in your deploy pipeline so it never happens again.

Layered caching is not the villain here. Each layer earns its place, and the site is dramatically faster with all three. The villain is a deploy process that changes content without telling the caches. Fix the process, keep the layers.