Cache Expensive Operations with the WordPress Transients API

The WordPress Transients API provides a standardized interface for storing arbitrary cached data with an expiration time — it abstracts over the underlying storage mechanism so that the same set_transient() / get_transient() calls use the wp_options table on a default installation and an in-memory store (Memcached, Redis) when an object cache is active, without requiring any code changes. A transient is identified by a string key (maximum 172 characters), stores a PHP value (serialized automatically), and expires after a specified number of seconds — expired transients are deleted on the next read attempt rather than by a background process, so an occasional stale read triggering a cache miss and regeneration is the normal eviction path. The typical usage pattern is the cache-aside pattern: get_transient() returns false on a miss or expiry, the calling code regenerates the expensive data, and set_transient() stores it with a new TTL. Common WordPress candidates for transients are: remote API responses (weather widgets, social media counts, currency exchange rates), complex WP_Query results that aggregate data across many posts or use meta_query joins, computed statistics dashboards, and custom search index results. Site transients (set_site_transient()) are network-wide on Multisite and are appropriate for data shared across all sites in a network. Cache invalidation is the hardest problem: transients should be invalidated proactively when their source data changes — for example, a transient caching a post’s computed reading time should be deleted when the post is updated via the save_post hook, ensuring the next page load recalculates and stores a fresh value. Transient keys should be namespaced (e.g., myplugin_top_posts_v2_30d) to avoid collisions with other plugins and to allow targeted bulk deletion with a LIKE query when a schema or calculation changes. The Redis object caching post explains how enabling a persistent object cache upgrades transients from database-backed storage to in-memory storage automatically.

Problem: A WordPress dashboard widget queries the GitHub API to display contributor statistics for a plugin repository — each admin page load fires an HTTP request to the GitHub API, causing 500ms+ load times, rate-limit errors after a few refreshes, and broken widgets when the API is unreachable.

Solution: Wrap the API call in the Transients API cache-aside pattern with a 1-hour TTL, proactively invalidate the transient via a scheduled event that pre-warms the cache, and store the last successful response as a fallback for when the API is down.

define( 'MYPLUGIN_GH_STATS_KEY', 'myplugin_gh_stats_v1' );
define( 'MYPLUGIN_GH_STATS_TTL', HOUR_IN_SECONDS );

/**
 * Get GitHub contributor stats, served from transient cache when available.
 *
 * @param  string $repo  e.g. "owner/repo-name"
 * @return array|false   Parsed stats array or false on unrecoverable failure.
 */
function myplugin_get_github_stats( string $repo ): array|false {
    $cache_key = MYPLUGIN_GH_STATS_KEY . '_' . sanitize_key( $repo );

    // 1. Return cached value if available
    $cached = get_transient( $cache_key );
    if ( false !== $cached ) {
        return $cached;
    }

    // 2. Fetch fresh data from the GitHub API
    $url      = 'https://api.github.com/repos/' . rawurlencode( $repo ) . '/contributors';
    $response = wp_remote_get( $url, [
        'timeout' => 5,
        'headers' => [
            'Accept'     => 'application/vnd.github.v3+json',
            'User-Agent' => 'WordPress/' . get_bloginfo('version'),
        ],
    ] );

    if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
        // 3. On failure, return stale data stored as fallback (no TTL = permanent)
        $fallback = get_option( $cache_key . '_fallback' );
        return $fallback ?: false;
    }

    $body = json_decode( wp_remote_retrieve_body( $response ), true );
    if ( ! is_array( $body ) ) {
        return false;
    }

    // Sanitize before storing
    $stats = array_map( function( $contributor ) {
        return [
            'login'         => sanitize_text_field( $contributor['login'] ?? '' ),
            'contributions' => absint( $contributor['contributions'] ?? 0 ),
            'avatar_url'    => esc_url_raw( $contributor['avatar_url'] ?? '' ),
        ];
    }, array_slice( $body, 0, 10 ) );

    // 4. Cache for 1 hour and save as fallback
    set_transient( $cache_key, $stats, MYPLUGIN_GH_STATS_TTL );
    update_option( $cache_key . '_fallback', $stats, false );  // autoload=false

    return $stats;
}

// ── Proactive cache invalidation on post save ───────────────────────────────
add_action( 'save_post', function( int $post_id ): void {
    // Bust a post-specific transient when post content changes
    delete_transient( 'myplugin_reading_time_' . $post_id );
} );

// ── Scheduled pre-warming to avoid cold-cache spikes ───────────────────────
add_action( 'init', function(): void {
    if ( ! wp_next_scheduled( 'myplugin_prewarm_github_cache' ) ) {
        wp_schedule_event( time(), 'hourly', 'myplugin_prewarm_github_cache' );
    }
} );
add_action( 'myplugin_prewarm_github_cache', function(): void {
    delete_transient( MYPLUGIN_GH_STATS_KEY . '_owner_my-plugin' );
    myplugin_get_github_stats( 'owner/my-plugin' );
} );

// ── Bulk-delete all plugin transients on plugin update ─────────────────────
register_activation_hook( __FILE__, function(): void {
    global $wpdb;
    $wpdb->query(
        "DELETE FROM {$wpdb->options}
         WHERE option_name LIKE '\_transient\_myplugin\_%'
            OR option_name LIKE '\_transient\_timeout\_myplugin\_%'"
    );
} );

NOTE: Transients stored in the wp_options table with autoload='yes' (the default for options, but NOT for transients — WordPress sets autoload to no for transients automatically) do not affect the initial options load. However, the stale transients accumulate in the table if a persistent object cache is not active and WordPress’s pseudo-cron cleanup misses them. Run DELETE FROM wp_options WHERE option_name LIKE '_transient_timeout_%' AND option_value < UNIX_TIMESTAMP() periodically, or use WP-CLI’s wp transient delete --expired command to keep the options table lean.