Transients

Cached values with an expiry that is a ceiling, not a promise, and what to do about cache stampedes.

Syntaxset_transient( string $transient, mixed $value, int $expiration = 0 ): bool

A transient is a cached value with a time limit: set_transient(), get_transient(), delete_transient(). Use them for anything expensive and reproducible — a remote API response, an aggregate query, a rendered fragment.

Where the value goes

With no persistent object cache, a transient is two rows in wp_options: _transient_{name} holding the value and _transient_timeout_{name} holding a Unix expiry. Neither is autoloaded when you set an expiry; with $expiration = 0 the value row is autoloaded and read on every request. When a persistent object cache is installed, transients go through wp_cache_set() instead and never touch the database. Names must be 172 characters or fewer.

Expiry is a ceiling

The value can disappear at any moment before the expiry — an object cache evicts under memory pressure, a deploy flushes it, someone clears the cache. Nothing keeps it alive. Code that calls get_transient() must always be able to rebuild the value. In the other direction, an expired database transient sits in wp_options until something reads it (get_transient() deletes it) or the daily delete_expired_transients event sweeps it, which is why abandoned plugins leave thousands of dead rows behind.

Stampedes

When a popular transient expires, every concurrent request misses at once and every one of them rebuilds. Serve stale data while a single request refreshes, using a short lock transient, or regenerate on a cron event and let the front end only ever read.

Example

Example · php
/**
 * Fetch exchange rates, cached for 12 hours.
 * Every caller of a transient must be able to rebuild the value:
 * the cache can be evicted at any moment, not just at the expiry.
 */
function acme_get_exchange_rates(): array {
    $key   = 'acme_exchange_rates';
    $rates = get_transient( $key );

    // === false, because an empty array or 0 is a valid cached value.
    if ( false !== $rates ) {
        return $rates;
    }

    $response = wp_remote_get( 'https://api.example.com/rates', array( 'timeout' => 5 ) );

    if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
        // Cache the failure briefly so a dead API is not hit on every
        // page view. Store an empty array, never false.
        set_transient( $key, array(), 5 * MINUTE_IN_SECONDS );
        return array();
    }

    $rates = json_decode( wp_remote_retrieve_body( $response ), true );

    if ( ! is_array( $rates ) ) {
        $rates = array();
    }

    set_transient( $key, $rates, 12 * HOUR_IN_SECONDS );

    return $rates;
}

When to use it

  • Caching a third-party API response so a page view does not depend on someone else's uptime.
  • Holding an expensive aggregate query result for a dashboard widget.
  • Rate-limiting a background job by setting a short lock transient.
  • Caching a rendered fragment and clearing it from save_post rather than waiting for the expiry.

More examples

What set_transient() actually writes

Knowing the storage shape explains two things people hit in production: why a transient with no expiry shows up in an autoload audit, and why transients vanish the moment Redis is enabled on a site that was relying on them persisting in the database.

Example · php
// Shorthand:
set_transient( 'acme_report', $data, HOUR_IN_SECONDS );

// Longhand - with no persistent object cache, this is what WordPress does:
// two options, neither autoloaded, one holding the value and one the expiry.
add_option( '_transient_timeout_acme_report', time() + HOUR_IN_SECONDS, '', false );
add_option( '_transient_acme_report', $data, '', false );

// With no expiry the value row IS autoloaded, so it is fetched on every
// request whether you read it or not. This is a real cost:
set_transient( 'acme_flag', 1 );
// equivalent to: add_option( '_transient_acme_flag', 1, '', true );

// With a persistent object cache installed, none of the above happens -
// the value goes to wp_cache_set( 'acme_report', $data, 'transient', 3600 )
// and the options table is never touched.

// Find the dead rows an abandoned plugin left behind:
global $wpdb;
$stale = $wpdb->get_col(
    $wpdb->prepare(
        "SELECT option_name FROM {$wpdb->options}
          WHERE option_name LIKE %s AND option_value < %d",
        $wpdb->esc_like( '_transient_timeout_' ) . '%',
        time()
    )
);

Serving stale data instead of stampeding

Use this once a cached value is expensive enough that rebuilding it twenty times at once would hurt — a dashboard aggregate, a third-party API with a rate limit. The traffic spike at expiry is what takes sites down, not the cost of a single rebuild.

Example · php
/**
 * Soft expiry: the transient itself has a long life, and freshness is
 * tracked inside the payload. When it goes stale one request refreshes
 * while everyone else keeps getting the old value.
 */
function acme_report(): array {
    $key    = 'acme_report';
    $cached = get_transient( $key );
    $now    = time();

    if ( is_array( $cached ) && $cached['fresh_until'] > $now ) {
        return $cached['data']; // Fresh.
    }

    // Stale or missing. Take a short lock; add_option() style atomicity is
    // not available here, so keep the lock brief and accept the small race.
    $has_lock = false === get_transient( $key . '_lock' );

    if ( ! $has_lock && is_array( $cached ) ) {
        return $cached['data']; // Another request is rebuilding - serve stale.
    }

    set_transient( $key . '_lock', 1, 60 );

    $data = acme_build_report(); // Expensive.

    set_transient(
        $key,
        array( 'data' => $data, 'fresh_until' => $now + ( 15 * MINUTE_IN_SECONDS ) ),
        DAY_IN_SECONDS // Hard expiry well beyond the soft one.
    );
    delete_transient( $key . '_lock' );

    return $data;
}

Invalidating on write instead of waiting for the clock

Event-driven invalidation gives you both a long cache life and correct data. Pairing it with a cron refresh means the front end never pays the rebuild cost at all — the worst case is one stale hour, not one slow request.

Example · php
// A short expiry is a guess. If you know when the data changed, delete
// the transient then and use a long expiry as the safety net.
add_action( 'save_post_product', function ( int $post_id ): void {
    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
        return;
    }

    delete_transient( 'acme_product_feed' );
    delete_transient( 'acme_product_count' );
}, 10, 1 );

// Terms change the feed too.
add_action( 'edited_product_cat', function (): void {
    delete_transient( 'acme_product_feed' );
} );

// Rebuild ahead of the request rather than during it.
add_action( 'acme_refresh_feed', function (): void {
    set_transient( 'acme_product_feed', acme_build_feed(), DAY_IN_SECONDS );
} );

if ( ! wp_next_scheduled( 'acme_refresh_feed' ) ) {
    wp_schedule_event( time(), 'hourly', 'acme_refresh_feed' );
}

Discussion

  • Be the first to comment on this lesson.