v1.2.0 — "The Cold Case"
Welcome to the Cache Warmer—a single-file mu-plugin that keeps low-traffic WordPress sites warm, measures cache health while it does it, and tells you the truth about whether warming is even your problem.
Your edge cache forgets. Your page cache forgets. max-age=86400 is less of a promise and more of a mood. And the first visitor after every eviction—a real human, a Googlebot, a PageSpeed test, a prospect your sales team is trying to impress—pays the full cold-render tax while everyone else gets the 70ms version. This tool makes sure somebody already paid that tax before anyone important shows up.
No known bugs at this time.
- 🔥 Keep the pages that get judged warm — front page, key landings, whatever PSI and prospects actually hit
- 🔍 Measure cache health continuously — every fetch logs which layer served it and how fast
- 🧠 Tell you the truth — sometimes the right answer is not this tool, and the report says so
- 🦥 Work on zero-traffic sites — the exact sites every other warming strategy quietly fails on
Two facts about managed WordPress caching that nobody puts in the brochure:
-
max-age ≠ stays-warm. Edge and page caches are LRU-bounded. A low-traffic site's entries get evicted regardless of TTL—measured in minutes at the edge on idle sites. Cold render: 1–4s+. Warm hit: ~70ms. That's a 35× tax on whoever shows up first.
-
You can't self-schedule your way out. On hosts without a real cron runner, WP-cron only fires when PHP runs—and PHP only runs on a cache MISS. So a WP-cron-scheduled warmer is a perpetual motion machine: it dies the moment it succeeds. Warm site → all HITs → no PHP → no cron → eviction → cold. Physics wins.
The fix for #2 is admitting the system needs an external heartbeat. That's the whole architecture.
-
A tick endpoint. The mu-plugin answers
/?pscw_tick=<secret>&t=<timestamp>—validated, never-cached, interval-gated (early ticks are an instant 204 no-op). Each tick runs ONE time-boxed warm/measure cycle atmuplugins_loadedand exits before your theme even wakes up. -
Any dumb pinger drives it. GitHub Actions cron. An uptime monitor you already pay for. A curl loop. One request per site per ~5 minutes. The pinger needs zero intelligence—all the thinking lives in-site.
-
Two-tier warming (in
warmmode):- Hot set — ≤10 curated URLs, every ~4 minutes. The pages that get judged. Always warm.
- Broad set — sitemap-driven, capped, hourly, cursor-resumed across cycles. The long tail.
-
Purge-aware. Content changes → platform purges the page → the warmer's queue re-warms it about a minute later. Your editor saves; your cache doesn't sulk about it.
-
Polite by construction. One concurrent fetch, ever. Configurable sleep between requests. A responsiveness pre-check that skips the whole cycle if the site is struggling. Time-boxed cycles. This tool must never become the load problem it exists to solve. (Looking at you, every crawler-based preloader ever built.)
Every fetch is the measurement. The warming stream and the diagnostic stream are the same requests—cycle N verifies cycle N−1 for free. Each record logs the URL, HTTP status, timing, x-ac (edge state), Batcache visibility, Set-Cookie presence, and cacheability headers.
Three serving layers, classified per fetch:
| Layer | Signal | Typical cost |
|---|---|---|
edge |
x-ac HIT or STALE (stale-while-revalidate = instant for the visitor) | ~20–100ms |
bcache |
x-ac MISS but fast — served by the origin page cache | ~40–150ms |
render |
x-ac MISS and slow — full PHP | 1–4s+ 💸 |
Then wp pscw report hands down a verdict:
| Verdict | Meaning | Action |
|---|---|---|
HEALTHY-WARM |
≥80% of responses avoid a full render | turn the warmer off, you don't need it |
NEEDS-WARMING |
most responses pay full PHP | enable warm mode |
CACHE-POISONED |
anonymous responses carry Set-Cookie | fix the cookies first — warming cannot help an uncacheable site |
inconclusive |
not enough data | keep measuring |
The report also flags uncacheable URLs (no-cache/no-store/private responses—password-protected content, plugin nocache, 404 paths) and non-200s (wrong URLs, slug drift). These flags exist because we stepped on every one of these rakes personally. The sitemap-driven URL selection and per-fetch status logging are not features, they're scar tissue.
Install in measure mode (the default), let it watch for a few days, read the report, then decide. Data first, warming second. Sometimes the verdict is "your cache is fine" or "your real problem is a rogue cookie"—and that's the tool working, not failing.
- Copy
pressable-solutions-cache-warmer.phpintowp-content/mu-plugins/. - (Optional) Copy
pscw-config.example.jsontowp-content/mu-plugins/pscw-config.jsonand tune it. Config hot-reloads—no code edits, no restarts, no excuses. - Get your tick URL:
wp pscw secret - Point a pinger at it (see
pinger-examples/). Append a changing&t=<timestamp>. - Wait a few days. Then:
wp pscw report
You're ready to simmer. 🍲
wp pscw status # config + aggregates + cursor + log location
wp pscw secret [--rotate] # tick secret + full tick URL
wp pscw run # force one cycle now (testing)
wp pscw report [--days=7] # per-URL layer stats + the verdict
- We warm the origin page cache (global) + the POP the fetch traverses. Full multi-POP edge warmth is a platform-layer concern—the 1–4s PHP render is the cost that matters, and origin-cache warmth kills it everywhere.
- If the report shows
x-scheaders, a platform-level preload layer is active on your site—don't double-warm. - Single-site WordPress (v1). Multisite untested.
- A warmer cannot fix an uncacheable site. The verdict will tell you. Believe it.
{
"mode": "measure",
"hot_urls": ["/", "/about/", "/contact/"],
"broad_source": "sitemap",
"broad_cap": 100,
"hot_interval_s": 240,
"broad_interval_s": 3600,
"sleep_ms": 2000,
"fetch_timeout_s": 10,
"cycle_budget_s": 90,
"backoff_ms": 3000,
"log_max_bytes": 1048576,
"user_agent": "Pressable-Solutions-CacheWarmer/1.0"
}Kill switch: define('PSCW_DISABLED', true); in wp-config.php. Or delete the file. We're a mu-plugin, not a lifestyle.
GPL-2.0-or-later. Same as WordPress, because that's how this works.
Because v1.2 shipped after a day spent investigating cache entries that kept turning up dead—and the culprit was never who we suspected first. The eviction wasn't an eviction, the poisoning wasn't a poisoning, and the 404 was coming from inside the house. Every diagnostic flag in the report is a closed case.