WordPress Transient Caching Patterns: Advanced Use with Expiry Groups

WordPress transients are a simple key-value cache stored in the wp_options table (or in Redis/Memcached when a persistent object cache is active). Beyond the basic set_transient()/get_transient() calls, a disciplined pattern of expiry-group keys, serialised complex data, and hook-driven invalidation prevents stale content and database bloat from accumulating over time.

Problem: WordPress transients are used for caching but without a systematic approach — some expire, some do not, stale transients from uninstalled plugins persist in wp_options, and cache-busting during updates causes cache stampedes.

Solution: Design transients with versioned keys (my_plugin_v2_transient_{id}), set realistic expiries, and hook into save_post/edited_term to delete specific transients rather than calling delete_transient on every save. Prevent stampedes with a lock flag — check if a background process is already regenerating the transient before starting a second regeneration.


The code below shows a group-expiry pattern using a version key, how to cache paginated query results per page, automatic invalidation on post save, and a cleanup routine that removes orphaned transients from the options table.


<?php
// ── 1. Group expiry via a version key ────────────
function get_news_transient_key( int $page = 1 ): string {
    $version = (int) get_transient( 'news_cache_version' ) ?: 1;
    return "news_page_{$page}_v{$version}";
}

function bump_news_cache_version(): void {
    $v = (int) get_transient( 'news_cache_version' ) ?: 1;
    set_transient( 'news_cache_version', $v + 1 );  // no expiry — persists until changed
}

// Invalidate all news transients when any news post is saved
add_action( 'save_post_post', function ( int $post_id ) {
    if ( has_category( 'news', $post_id ) ) {
        bump_news_cache_version();
    }
} );

// ── 2. Cache paginated query results ────────────
function get_cached_news( int $page = 1 ): array {
    $key    = get_news_transient_key( $page );
    $cached = get_transient( $key );
    if ( false !== $cached ) {
        return $cached;
    }

    $query = new WP_Query( [
        'category_name'  => 'news',
        'paged'          => $page,
        'posts_per_page' => 10,
        'no_found_rows'  => false,
    ] );

    $data = [
        'posts'      => $query->posts,
        'max_pages'  => $query->max_num_pages,
        'total'      => $query->found_posts,
    ];

    set_transient( $key, $data, HOUR_IN_SECONDS );
    return $data;
}

// ── 3. Clean up expired transients from the options table ────────────
// (Only needed when NOT using a persistent object cache)
function delete_expired_transients(): void {
    global $wpdb;
    $wpdb->query(
        $wpdb->prepare(
            "DELETE o, ot
             FROM {$wpdb->options} o
             INNER JOIN {$wpdb->options} ot ON ot.option_name = REPLACE(o.option_name,'_transient_timeout_','_transient_')
             WHERE o.option_name LIKE %s
               AND o.option_value < %d",
            $wpdb->esc_like( '_transient_timeout_' ) . '%',
            time()
        )
    );
}
add_action( 'wp_scheduled_delete', 'delete_expired_transients' );


NOTE: When a persistent object cache (Redis/Memcached) is active, transients are stored in the cache backend — not in wp_options — and the cleanup routine above is unnecessary; the cache backend handles expiry natively and far more efficiently.