There are two hard problems in computer science: cache invalidation and naming things. We picked the first one. Here is how we think about purging cache when content changes on a WordPress site, and why the naive approach fails in subtle ways that bite three weeks after launch.
The naive approach
When a post saves, purge that URL. Done. Right?
add_action('save_post', function ($post_id) {
$url = get_permalink($post_id);
$cache_key = md5($url);
delete_transient("page_cache_$cache_key");
});This is what most plugins do. It works for the post itself. It fails for everything else.
What else needs to be purged
When you update a single blog post, several other cached pages now contain stale snippets of that post:
- The category archive shows the post in its list. The list now has the wrong title or excerpt.
- Every tag archive the post is tagged with. Same problem.
- The homepage if it shows recent posts, especially the latest one.
- The author archive for the post's author.
- Search results pages that contain the post (these usually are not cached, but if they are, they go stale).
- The RSS feed. Old XML in cache means stale entries served to feed readers.
- The XML sitemap. Same issue, plus search engines pick up cached XML.
- If Cloudflare or another edge cache is in front, all of the above need to be purged at the edge too, otherwise visitors hit cached HTML before reaching origin.
Building the graph
For each content change, we compute the set of URLs that depend on it. The relationships are deterministic and queryable from WordPress directly.
function hbm_rocket_purge_graph_for_post($post_id) {
$urls = [];
$urls[] = get_permalink($post_id);
// Categories
foreach (wp_get_post_categories($post_id) as $cat_id) {
$urls[] = get_category_link($cat_id);
}
// Tags
foreach (wp_get_post_tags($post_id) as $tag) {
$urls[] = get_tag_link($tag->term_id);
}
// Custom taxonomies
$taxonomies = get_object_taxonomies(get_post_type($post_id));
foreach ($taxonomies as $tax) {
foreach (wp_get_post_terms($post_id, $tax) as $term) {
$urls[] = get_term_link($term);
}
}
// Author archive
$urls[] = get_author_posts_url(get_post_field('post_author', $post_id));
// Homepage if recent
$is_recent_enough = ((time() - strtotime(get_post_field('post_date', $post_id))) < 30 * DAY_IN_SECONDS);
if ($is_recent_enough) $urls[] = home_url('/');
// Feed and sitemap
$urls[] = get_feed_link();
$urls[] = home_url('/sitemap.xml');
$urls[] = home_url('/sitemap_index.xml');
return array_unique($urls);
}Two purge layers
For sites with Cloudflare in front, every URL in the graph must be purged twice: once locally (on disk page cache), once at the edge (via Cloudflare API).
add_action('save_post', function ($post_id) {
$urls = hbm_rocket_purge_graph_for_post($post_id);
// Local disk cache
foreach ($urls as $u) {
$key = md5($u . '|m'); // mobile variant
@unlink(WP_CONTENT_DIR . "/cache/hbm-rocket/$key.html");
$key = md5($u . '|d'); // desktop variant
@unlink(WP_CONTENT_DIR . "/cache/hbm-rocket/$key.html");
}
// Edge cache via Cloudflare (when integration is connected)
hbm_rocket_cloudflare_purge_urls($urls);
});The warmup that follows
Right after purge, we crawl the same URL set in parallel to repopulate the cache. The first visitor never hits a cold cache. Warmup is queued, not synchronous, so save_post returns immediately.
// In the control plane worker
async function warmup(siteId, urls) {
const tasks = urls.map((url) => fetch(url, { headers: { "User-Agent": "HBM-Rocket-Warmup/0.1" } }));
await Promise.allSettled(tasks);
}WooCommerce and dynamic fragments
WooCommerce adds a wrinkle. Product pages contain pricing, stock count, and personalized recommendations that change without save_post firing. We use ESI (when LiteSpeed is available) or AJAX rehydration (everywhere else) for fragments that should not be cached.
For pure product update flow:
woocommerce_product_object_updated_propsfires on price or stock change. We treat it like save_post for the product URL.woocommerce_update_productcovers admin edits to product taxonomy and category.- Cart and checkout never cache. They are bypassed by cookie based rule.
What we do not purge
The result
For our average HBM client site we measure cache hit rates above 96 percent on origin and 99 percent at the Cloudflare edge. Save_post events execute the purge graph in 200 to 600 ms (network calls to Cloudflare are async). Visitors never see stale content for more than the time it takes the agent to send the event and the worker to react, typically under 30 seconds.