Implement Stale-While-Revalidate Caching in WordPress with Transients and Background Refresh

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.