The stale-while-revalidate (SWR) caching strategy serves a cached response immediately — even if it is expired — while triggering a background refresh to update the cache for the next request. This eliminates the cache-miss latency spike that occurs with traditional TTL-based caching, where the first request after expiry pays the full cost of the uncached operation. WordPress transients expire hard: once a transient’s TTL passes, the next get_transient() call returns false and the caller must regenerate the data synchronously, blocking the request for the duration of the expensive operation. SWR with transients requires storing two pieces of data: the cached value and a separate expiry sentinel. The sentinel has a shorter TTL (the “fresh” window); the value transient has a longer TTL (the “stale” window). When the sentinel is missing but the value is still present, the data is in the stale window — serve it immediately and schedule an async refresh via wp_schedule_single_event() with a hook that regenerates and stores the new value. The async hook fires via the next wp-cron trigger, decoupled from the user’s request. A mutex transient prevents multiple simultaneous requests in the stale window from scheduling duplicate refresh jobs — only the first request that finds the sentinel missing sets the mutex and schedules the job; subsequent requests in the same second serve the stale value and skip scheduling. This pattern is especially effective for API-dependent data (exchange rates, weather feeds, social counters) and for expensive aggregation queries (dashboard statistics, popular-posts lists) where occasional staleness is acceptable but zero-latency serving is required. For guaranteed background execution on low-traffic sites, replace wp_schedule_single_event() with a non-blocking wp_remote_post() with blocking => false, or use Action Scheduler for reliable job queuing. The async CSS loading post applies the same background-processing principle to stylesheet generation — SWR is the data-layer equivalent of deferred critical-CSS computation.
Problem: Transient-cached data causes a latency spike on the first request after TTL expiry because the expensive regeneration blocks the response — high-traffic pages may see many simultaneous cache-miss requests all regenerating the same data concurrently (cache stampede).
Solution: Store the cached value with a long TTL and a separate freshness sentinel with a short TTL; serve the stale value immediately when only the sentinel is expired, and schedule a background wp-cron job to refresh the cache asynchronously using a mutex transient to prevent duplicate jobs.
/**
* Stale-While-Revalidate transient helper.
*
* @param string $key Unique cache key.
* @param callable $generator Returns the fresh value to cache.
* @param int $fresh_ttl Seconds the value is considered fresh.
* @param int $stale_ttl Seconds the value may be served stale.
* @return mixed
*/
function swr_get(string $key, callable $generator, int $fresh_ttl = 300, int $stale_ttl = 3600) {
$val_key = 'swr_val_' . $key;
$fresh_key = 'swr_fresh_' . $key;
$mutex_key = 'swr_mutex_' . $key;
$value = get_transient($val_key); // long-lived cached value
$fresh = get_transient($fresh_key); // short-lived freshness sentinel
if ($value !== false && $fresh !== false) {
return $value; // fully fresh
}
if ($value !== false && $fresh === false) {
// Stale window — serve stale, schedule background refresh
if (get_transient($mutex_key) === false) {
set_transient($mutex_key, 1, 30); // 30-second mutex
wp_schedule_single_event(time() + 1, 'swr_refresh_cache', [$key, $fresh_ttl, $stale_ttl]);
}
return $value;
}
// Cold start — generate synchronously
$value = call_user_func($generator);
set_transient($val_key, $value, $stale_ttl);
set_transient($fresh_key, 1, $fresh_ttl);
return $value;
}
// Cron callback: regenerate value and clear mutex
add_action('swr_refresh_cache', function(string $key, int $fresh_ttl, int $stale_ttl) {
$value = apply_filters('swr_generate_' . $key, null);
if ($value !== null) {
set_transient('swr_val_' . $key, $value, $stale_ttl);
set_transient('swr_fresh_' . $key, 1, $fresh_ttl);
}
delete_transient('swr_mutex_' . $key);
}, 10, 3);
// Usage: cache an expensive popular-posts query
// Register the generator for background refresh
add_filter('swr_generate_popular_posts', function() {
global $wpdb;
return $wpdb->get_results(
"SELECT p.ID, p.post_title, pm.meta_value AS views
FROM {$wpdb->posts} p
JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_post_views'
WHERE p.post_status = 'publish'
ORDER BY CAST(pm.meta_value AS UNSIGNED) DESC
LIMIT 10"
);
});
// In your template:
$popular = swr_get(
'popular_posts',
fn() => apply_filters('swr_generate_popular_posts', null),
5 * MINUTE_IN_SECONDS, // fresh for 5 min
HOUR_IN_SECONDS // stale up to 1 hour
);
foreach ((array) $popular as $post) {
echo esc_html($post->post_title) . "\n";
}
NOTE: wp-cron fires only when a WordPress page is loaded — on low-traffic sites the background refresh may be delayed by minutes. For guaranteed sub-second background execution, replace wp_schedule_single_event() with a non-blocking HTTP request to admin-ajax.php using wp_remote_post() with 'blocking' => false, or use Action Scheduler for reliable job queuing.