Transients
Cached values with an expiry that is a ceiling, not a promise, and what to do about cache stampedes.
set_transient( string $transient, mixed $value, int $expiration = 0 ): boolA 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
/**
* 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.
// 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.
/**
* 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.
// 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