Options, Transients and Object Cache

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 8.4 (WordPress needs PHP 7.4 or later and recommends 8.3 or later), WP-CLI 2.12.0 and the Redis Object Cache plugin 3.0.0, and checked on 2026-10-28. WordPress 7.2, in beta and due in December 2026, does not change these APIs so far. It covers the developer side of the three key/value stores that every plugin uses: options for settings and state that must persist, transients for data that can be recomputed, and the object cache for data that is expensive to read within and, with a persistent backend, across requests. The examples belong to the lumen-catalog plugin of the fictitious publisher Lumen Press.

Performance and Caching sets up the Redis Object Cache drop-in and measures autoloaded options; settings screens (register_setting(), options.php) are in Administration Menus and Settings API, and the wp_options table in Database Schema.

Which store for which data

Store Lives in Survives Use it for

Option

wp_options (wp_sitemeta for network options)

Until deleted

Settings and state the plugin cannot rebuild: lp_catalog_settings, the schema version lumen_catalog_db_version, the last good copy of an API answer

Transient

wp_options without a persistent object cache; the object cache with one

At most until it expires; possibly much less

Results that can be recomputed or fetched again: an API answer, a costly query, rendered HTML

Object cache

Process memory (default); Redis, Memcached or APCu with a drop-in

One request (default); until evicted or expired (persistent)

Data read several times per request or per minute: post objects, query results, the card data of a book

Post meta, custom table

wp_postmeta, {$wpdb->prefix}lp_stock

Until deleted

Data that belongs to a post or must be queried, such as the lp_stock level of each book

Anything that would be lost for good if it disappeared belongs in an option, meta or a table. Transients and the object cache are caches: code that reads them must always be able to rebuild the value.

The Options API

Function Behaviour

get_option( $option, $default_value = false )

Returns the stored value, unserialized. Returns $default_value when the option does not exist

add_option( $option, $value = '', $deprecated = '', $autoload = null )

Adds an option; returns false if it already exists. The third argument is unused

update_option( $option, $value, $autoload = null )

Updates an option, or adds it when missing. Returns false when nothing changed

delete_option( $option )

Deletes the row and its cache entries

get_options( $options ), wp_prime_option_caches( $options ) (6.4)

Return, or only load into the cache, several options with one query

wp_set_option_autoload_values( $options ) (6.4)

Changes autoload of several options without touching their values; wp_set_option_autoload() and wp_set_options_autoload() are the single and same-value forms

get_site_option(), update_site_option(), …​

Network-wide options, stored in wp_sitemeta on Multisite and in wp_options on a single site

Values are serialized with maybe_serialize(), so arrays and objects can be stored directly; do not call serialize() or json_encode() yourself unless another program reads the row. An option name is at most 191 characters (the option_name column), and it must carry the plugin’s prefix, because every plugin and core share one namespace. The names alloptions and notoptions are reserved: wp_protect_special_option() stops any write to them.

How get_option() finds a value

  1. The filters pre_option_{$option} and pre_option (6.1) can short-circuit the call by returning anything but false. A test can force a value this way without writing to the database.

  2. wp_load_alloptions() has already loaded every autoloaded option, in one query, at the start of the request. If the option is there, it is returned.

  3. Otherwise the notoptions entry of the options cache group lists names known not to exist, so a missing option costs one query per request (or none with a persistent object cache).

  4. Otherwise the value is read from the options cache group, and on a miss with its own SELECT, then cached.

  5. A missing option returns the result of the default_option_{$option} filter, which register_setting() uses to supply a registered default. The value then passes through the option_{$option} filter.

That is why a non-autoloaded option is not slow in itself: it costs one query the first time per request, and none when a persistent object cache holds it. get_options() or wp_prime_option_caches() reduce several such reads to one query.

Autoload since WordPress 6.6

The autoload column decides whether wp_load_alloptions() reads a row on every request. WordPress 6.6 replaced the old yes and no with on and off for explicit choices, and auto, auto-on and auto-off for rows where the caller made no choice. auto is loaded today, but core reserves the right to change that. The full table of values, the 800 KB Site Health limit and SQL to measure a site are in Database Schema and Performance and Caching.

For plugin code, the rules are:

  • Always pass the autoload argument. true for small values that most requests read (a schema version, a feature flag used on the front end), false for anything read on one admin screen, by a cron job or by a REST route. Passing null (the default) leaves the decision to wp_determine_option_autoload_value().

  • Do not pass 'yes' or 'no'. They still work, but have been deprecated since 6.7.

  • Large values are not autoloaded automatically, but only when no choice was made. With null, a serialized value larger than 150,000 bytes (filter wp_max_autoloaded_option_size) is stored as auto-off. An explicit true autoloads it whatever its size.

  • update_option() only changes autoload when the value changes. For an existing option whose value is unchanged it returns false and keeps the old autoload. Use wp_set_option_autoload_values() to change autoload alone.

  • The wp_default_autoload_value filter (6.6) sets the value used when the caller passes null, including calls from core screens and the REST API that pass nothing. Returning false stores auto-off.

The following script shows the return values and the stored autoload for the calls the catalogue makes. Run on a fresh 7.1.3 site with wp eval-file options-check.php:

<?php
// options-check.php: run with `wp eval-file options-check.php`.
global $wpdb;

add_option( 'lumen_catalog_db_version', '3', '', true );           // read on every request
add_option( 'lp_catalog_settings', array( 'isbn_prefix' => '978' ), '', false );
add_option( 'lp_catalog_import_log', array() );                      // no choice: WordPress decides
add_option( 'lp_catalog_cover_index', str_repeat( 'x', 200000 ) );  // large, no choice

var_dump( update_option( 'lumen_catalog_db_version', '3' ) );  // unchanged value
var_dump( get_option( 'lp_catalog_missing' ) );                 // missing option
var_dump( get_option( 'lp_catalog_missing', array() ) );        // missing, with a default
var_dump( update_option( 'lp_catalog_enabled', false ) );      // missing option, false
add_option( 'lp_catalog_paused', false );
var_dump( get_option( 'lp_catalog_paused' ) );                  // stored false
echo $wpdb->get_var( "SELECT autoload FROM {$wpdb->options} WHERE option_name = 'lp_catalog_import_log'" ), "\n";

$changed = wp_set_option_autoload_values( array( 'lp_catalog_import_log' => false, 'lumen_catalog_db_version' => true ) );
echo wp_json_encode( $changed ), "\n";

foreach ( $wpdb->get_results(
	"SELECT option_name, autoload FROM {$wpdb->options}
	 WHERE option_name LIKE 'lp\_catalog\_%' OR option_name LIKE 'lumen\_catalog\_%' ORDER BY option_name"
) as $row ) {
	printf( "%-26s %s\n", $row->option_name, $row->autoload );
}

Output:

bool(false)
bool(false)
array(0) {
}
bool(false)
bool(false)
auto
{"lp_catalog_import_log":true,"lumen_catalog_db_version":false}
lp_catalog_cover_index     auto-off
lp_catalog_import_log      off
lp_catalog_paused          auto
lp_catalog_settings        off
lumen_catalog_db_version   on

Read top to bottom, the output shows the pitfalls of the API:

  • update_option() with an unchanged value returns false. Code that treats false as an error after an update logs false failures; compare with the old value first if the difference matters.

  • get_option() cannot tell a missing option from a stored false. Pass a default of another type (array(), null) when the difference matters.

  • update_option( 'lp_catalog_enabled', false ) on a missing option returns false and creates nothing: the missing value (false) and the new value are identical, so the function stops before falling through to add_option(). Store booleans as 0/1 or 'yes'/'no' strings, or use add_option() first.

  • A stored false comes back as false in the same request, but as the empty string '' in the next one (a separate wp eval printed string(0) ""), because false is saved as an empty column.

  • wp_set_option_autoload_values() returns, per option, whether the row changed (lumen_catalog_db_version was already on). The 200,000-byte option added without a choice became auto-off; the small one became auto.

Older books predate WordPress 6.6 and describe the column as yes/no with add_option() defaulting to yes. Both statements are out of date: the default is now null (WordPress decides), and WP-CLI’s wp option add stores on when --autoload is omitted, unlike add_option().

Transients

A transient is a named value with an optional expiration in seconds. The API has three functions per scope:

Function Notes

set_transient( $transient, $value, $expiration = 0 )

Name at most 172 characters. 0 means no expiration. Returns false if the value could not be stored, and, on the database backend, when the value did not change

get_transient( $transient )

Returns false when the transient is missing or expired, so do not store a bare false

delete_transient( $transient )

Returns true only if something was deleted

set_site_transient(), get_site_transient(), delete_site_transient()

Network-wide versions; name at most 167 characters

Values are serialized like options. Use a prefix (lumen_ for the catalogue) and, for derived keys, a short hash: 'lumen_search_' . md5( $query ) stays well under the length limit.

Where a transient is stored

wp_using_ext_object_cache() decides. Without a persistent object cache drop-in, a transient is two options: _transient_<name> with the value and, when an expiration is given, _transient_timeout_<name> with the expiry timestamp. Site transients use _site_transient_ and _site_transient_timeout_, in wp_options on a single site and in wp_sitemeta on Multisite. With a drop-in (and during installation), the same functions call wp_cache_set() in the transient or site-transient group with the expiration as TTL, and nothing is written to the database.

<?php
// transients-check.php: run with `wp eval-file transients-check.php`.
global $wpdb;

set_transient( 'lumen_featured_books', array( 101, 102 ), HOUR_IN_SECONDS );
set_transient( 'lumen_genre_counts', array( 'fiction' => 12 ) ); // no expiration
var_dump( set_transient( 'lumen_genre_counts', array( 'fiction' => 12 ) ) ); // same value again
set_site_transient( 'lumen_network_notice', 'Imprint launch', DAY_IN_SECONDS );

printf( "external object cache: %s\n", wp_using_ext_object_cache() ? 'yes' : 'no' );

foreach ( $wpdb->get_results(
	"SELECT option_name, autoload FROM {$wpdb->options}
	 WHERE option_name LIKE '%transient%lumen%' ORDER BY option_name"
) as $row ) {
	printf( "%-46s %s\n", $row->option_name, $row->autoload );
}

Without and then with the Redis drop-in described in Performance and Caching (after wp transient delete --all and wp cache flush):

bool(false)
external object cache: no
_site_transient_lumen_network_notice           off
_site_transient_timeout_lumen_network_notice   off
_transient_lumen_featured_books                off
_transient_lumen_genre_counts                  on
_transient_timeout_lumen_featured_books        off

bool(true)
external object cache: yes

With Redis, redis-cli --scan --pattern 'lumenpress:*lumen*' listed lumenpress:wp:transient:lumen_featured_books, lumenpress:wp:transient:lumen_genre_counts and lumenpress:wp:site-transient:lumen_network_notice. Two details in the database case matter:

  • A transient without expiration is autoloaded (on), and so is loaded on every request until deleted. Give every transient an expiration unless it is small and read on most requests.

  • Setting the same value again returns false on the database backend (it ends in update_option()) and true with Redis. Do not use the return value to detect errors.

Expiration is a maximum

The expiration is the longest time a transient may live, not a promise that it will. It disappears earlier when a persistent cache evicts it under memory pressure (Redis with allkeys-lru), restarts or is flushed (wp cache flush, a deployment, a plugin’s flush button); when another node uses a different cache (APCu is per server; see Scaling and High Availability); or when a plugin, a database upgrade or wp transient delete --all removes it.

On the database backend, expired transients are removed in two ways: get_transient() deletes an expired row when it is read, and the daily delete_expired_transients cron event (since 4.9) deletes the rest in bulk. Statements that expired transients stay in wp_options until a visitor reads them, still found in some tutorials and in the "Related" note of the WP_Object_Cache reference page, are out of date. With a persistent cache, delete_expired_transients() does nothing (unless $force_db is true), because the backend expires keys itself. The cron event is described in WP-Cron and Scheduled Tasks.

wp transient list, get, set, delete and delete --expired manage transients from WP-CLI. With a drop-in, wp transient delete --all warns that it only deletes those in the database, and wp cache flush is needed instead.

Transient hooks

Hook Purpose

pre_transient_{$transient} (filter)

Return anything but false to short-circuit get_transient(), for example in tests

transient_{$transient} (filter)

Filters the value read

pre_set_transient_{$transient}, expiration_of_transient_{$transient} (filters)

Change the value or the expiration before storing

set_transient_{$transient}, set_transient (actions)

Fire after a successful set. setted_transient was deprecated in 6.8 in favour of set_transient

delete_transient_{$transient}, deleted_transient (actions)

Fire before and after deletion

Site transients

The same hooks with site_transient in place of transient

The object cache API

The object cache is the layer the other two use: options are cached in the options group (with the alloptions and notoptions entries), transients in transient with a drop-in, and core caches posts, meta, terms, users, comments and, since 6.1, WP_Query results (post-queries group). Plugins use the same functions.

Function Behaviour

wp_cache_get( $key, $group = '', $force = false, &$found = null )

Returns the value or false; $found tells a cached false from a miss

wp_cache_set( $key, $data, $group = '', $expire = 0 )

Stores unconditionally

wp_cache_add()

Stores only if the key does not exist; returns false otherwise

wp_cache_replace()

Stores only if the key exists

wp_cache_delete( $key, $group = '' )

Removes one key

wp_cache_incr(), wp_cache_decr()

Atomic counters on a persistent backend

wp_cache_get_multiple() (5.5), wp_cache_set_multiple(), wp_cache_add_multiple(), wp_cache_delete_multiple() (6.0)

Several keys of one group in one round trip; missing keys come back as false

wp_cache_flush()

Empties the whole cache, for every site sharing the backend

wp_cache_flush_runtime() (6.0)

Empties only the in-memory copy of the current request

wp_cache_flush_group( $group ) (6.1)

Empties one group, if the backend supports it

wp_cache_supports( $feature ) (6.1)

add_multiple, set_multiple, get_multiple, delete_multiple, flush_runtime, flush_group

wp_cache_get_last_changed( $group ) (4.7), wp_cache_set_last_changed( $group ) (6.3)

A per-group timestamp used to invalidate derived keys

wp_cache_get_salted(), wp_cache_set_salted() and their _multiple forms (6.9)

Store a value with a salt (usually a last_changed value) and return it only while the salt matches

wp_cache_add_global_groups(), wp_cache_add_non_persistent_groups()

Declare groups shared by all sites of a network, or never sent to the persistent backend

Keys are strings or integers (an empty key triggers _doing_it_wrong since 6.1) and are unique per group. On Multisite, a non-global group is also scoped per site; switch_to_blog() switches it.

The default cache and drop-ins

Without a drop-in, WP_Object_Cache is a PHP array: it lives for one request, ignores $expire, and returns a clone of a cached object so that callers cannot change the cached copy. A drop-in (wp-content/object-cache.php) replaces it with a shared backend; WP-CLI’s wp cache type prints Default or the backend name. Code must give the same result with either, which means:

  • Never rely on a value surviving to the next request, and never rely on $expire to clean up (the default cache ignores it; Redis honours it).

  • Keep values serializable. A drop-in serializes them, so closures and resources cannot be cached, and objects come back as new instances.

  • Check wp_cache_supports() before the newer functions. Core defines all of them in cache-compat.php for older drop-ins, but the compatible wp_cache_flush_group() refuses to run (it triggers _doing_it_wrong and returns false) when the drop-in has not declared support. The Common APIs Handbook still says such a call may flush the whole cache, and its example calls a function that does not exist, wp_cache_flush_cache(); in 7.1.3 the compatible function flushes nothing.

Groups and non-persistent groups

A group is a namespace; the catalogue uses lumen_catalog. Core keeps counts, plugins and theme_json out of Redis as non-persistent groups, and a plugin does the same for data that must not outlive the request:

<?php
// In Plugin::boot(): this group stays in PHP memory even with Redis.
wp_cache_add_non_persistent_groups( array( 'lumen_catalog_request' ) );

A wp eval-file script exercised the functions in the lumen_catalog group. The results were identical with the default cache and with Redis:

Call Result

wp_cache_add( 'probe', 'first', ... ), then again with 'second'

true, then false: the key exists

wp_cache_replace( 'absent', 'x', ... )

false: the key is missing

wp_cache_set( 'flag', false, ... ), then wp_cache_get( 'flag', ..., false, $found )

false, with $found set to true

wp_cache_set( 'counter', 10, ... ), then wp_cache_incr( 'counter', 5, ... )

15

wp_cache_get_multiple( array( 'probe', 'counter', 'nope' ), ... )

first, 15 and false for the missing key

wp_cache_supports( 'flush_group' ), then wp_cache_flush_group( 'lumen_catalog' )

true, true; a following wp_cache_get( 'probe', ... ) returned false

With Redis, no key of the lumen_catalog_request group appeared in redis-cli --scan. Redis Object Cache 3.0.0 supports flush_group, and it rewrites : in keys to - (the key card:5 of the next section is stored as lumenpress:wp:lumen_catalog:card-5).

Cache invalidation patterns

A cached value is only correct if every change to its source removes or outdates it. Three patterns cover most cases:

Pattern How Use when

Delete on write

wp_cache_delete() or delete_transient() in the hook that changes the data (save_post_lp_book, updated_post_meta)

The cached value maps to one object, such as the card of one book

Last-changed salt

Read wp_cache_get_last_changed( $group ) and store derived values with it as salt (wp_cache_set_salted()), and call wp_cache_set_last_changed( $group ) on any change

Many derived keys (lists, counts, searches) depend on any object of a type. Core uses it for post, comment, term, user and site queries since 6.9

Expiry only

A short expiration and no hooks

The source is outside WordPress (a remote API) and slightly stale data is acceptable

Flushing a whole group with wp_cache_flush_group() works too, but it removes unrelated keys of the group and depends on backend support; the last-changed salt costs one key read.

The catalogue’s Lumen\Catalog\Cache class, booted from Plugin::boot() (see Plugin Fundamentals), caches the latest book IDs with a salt and the card data of each book under its own key, and clears both from hooks. save_post_lp_book is the dynamic save_post_{$post->post_type} action; it fires for every save of a book, including Quick Edit and REST, but not for its revisions, whose post type is revision; deleted_post_lp_book (6.6) covers permanent deletion. The lumen/latest-books block in Static and Dynamic Blocks can render from these two methods.

<?php
// Object cache helpers of the catalogue: lumen-catalog/src/Cache.php

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

final class Cache {

	public const GROUP = 'lumen_catalog';

	public static function boot(): void {
		add_action( 'save_post_lp_book', array( self::class, 'forget_book' ) );
		add_action( 'deleted_post_lp_book', array( self::class, 'forget_book' ) );
		add_action( 'updated_post_meta', array( self::class, 'forget_meta' ), 10, 3 );
		add_action( 'added_post_meta', array( self::class, 'forget_meta' ), 10, 3 );
	}

	/** IDs of the latest published books, valid until any book changes. */
	public static function latest_book_ids( int $count = 6 ): array {
		$salt = wp_cache_get_last_changed( self::GROUP );
		$key  = 'latest:' . $count;
		$ids  = wp_cache_get_salted( $key, self::GROUP, $salt );

		if ( false === $ids ) {
			$ids = get_posts(
				array(
					'post_type'     => 'lp_book',
					'post_status'   => 'publish',
					'numberposts'   => $count,
					'fields'        => 'ids',
					'no_found_rows' => true,
				)
			);
			wp_cache_set_salted( $key, $ids, self::GROUP, $salt, HOUR_IN_SECONDS );
		}

		return array_map( 'intval', $ids );
	}

	/** Card data (title, link, ISBN, stock) of several books, in one cache round trip. */
	public static function book_cards( array $ids ): array {
		$keys = array();
		foreach ( $ids as $id ) {
			$keys[ (int) $id ] = 'card:' . (int) $id;
		}

		$cached = wp_cache_get_multiple( array_values( $keys ), self::GROUP );
		$cards  = array();

		foreach ( $keys as $id => $key ) {
			$card = $cached[ $key ] ?? false;
			if ( false === $card ) {
				$card = array(
					'title' => get_the_title( $id ),
					'url'   => get_permalink( $id ),
					'isbn'  => (string) get_post_meta( $id, 'lp_isbn', true ),
					'stock' => (int) get_post_meta( $id, 'lp_stock', true ),
				);
				wp_cache_set( $key, $card, self::GROUP, DAY_IN_SECONDS );
			}
			$cards[ $id ] = $card;
		}

		return $cards;
	}

	/** Runs on save_post_lp_book and deleted_post_lp_book. */
	public static function forget_book( int $post_id ): void {
		wp_cache_delete( 'card:' . $post_id, self::GROUP );
		wp_cache_set_last_changed( self::GROUP );
	}

	/** Runs when post meta is added or updated, for example by the stock sync. */
	public static function forget_meta( $meta_id, int $object_id, string $meta_key ): void {
		if ( in_array( $meta_key, array( 'lp_isbn', 'lp_stock' ), true ) ) {
			wp_cache_delete( 'card:' . $object_id, self::GROUP );
		}
	}
}

The salted functions arrived in 6.9, which is the plugin’s Requires at least; on older versions the same pattern is written by putting the last_changed value into the key ("latest:{$count}:{$salt}"), as core did before 6.9. Old keys are then never deleted, only left to expire or be evicted, which is why the $expire argument is set. Two limits of hook-based invalidation: a direct SQL write ($wpdb->update(), an import tool) fires no hook, and update_post_meta() with an unchanged value fires no updated_post_meta. Code that writes that way calls wp_cache_set_last_changed() itself.

A wp eval-file script counted the queries of Cache::latest_book_ids( 3 ) called twice, then again after renaming the newest book with wp_update_post(), and read its card. With two books (IDs 4 and 5), both backends printed:

latest=5,4 queries=1
again: queries=0
after save: queries=1
The Lighthouse Ledger (2nd ed.)

Deleting a book with wp_delete_post( $id, true ) changed the salt as well. Across requests the backends differ: a following wp eval of the same call counted 0 database queries for the whole request with Redis and 8 without it.

Caching the distributor API response

The lp_catalog_sync_stock cron job of WP-Cron and Scheduled Tasks downloads the distributor’s stock list. Other code needs the same list within minutes: the per-book sync queued on save_post_lp_book, wp lumen import and the stock endpoint of the REST API. The distributor rate-limits its API, so the list is fetched once and kept in a transient, lumen_distributor_stock, for 15 minutes. The HTTP client that fetches and caches it, lumen_catalog_fetch_stock() in includes/distributor.php, is the one of HTTP and Filesystem APIs, which also defines the LP_CATALOG_STOCK_TRANSIENT constant and normalizes ISBNs to digits. It never caches a failure. Because the transient can vanish, and the distributor can be down, the function below wraps it and keeps the last good answer in a non-autoloaded option, lp_catalog_stock_snapshot. Page views never call either function; they read the lp_stock meta that the job writes.

<?php
// Stock list with a last-good fallback: lumen-catalog/includes/stock-cache.php
// Requires includes/distributor.php (lumen_catalog_fetch_stock() and LP_CATALOG_STOCK_TRANSIENT).

defined( 'ABSPATH' ) || exit;

const LP_CATALOG_STOCK_SNAPSHOT = 'lp_catalog_stock_snapshot';

/** Stock levels keyed by normalized ISBN: the transient, else the distributor, else the last good copy. */
function lp_catalog_get_distributor_stock( bool $refresh = false ): array {
	$stock = lumen_catalog_fetch_stock( $refresh );

	if ( ! is_wp_error( $stock ) ) {
		// update_option() writes nothing when the list is unchanged.
		update_option( LP_CATALOG_STOCK_SNAPSHOT, $stock, false );
		return $stock;
	}

	// The distributor failed: serve the last good copy and retry in five minutes.
	$snapshot = get_option( LP_CATALOG_STOCK_SNAPSHOT, array() );
	set_transient( LP_CATALOG_STOCK_TRANSIENT, $snapshot, 5 * MINUTE_IN_SECONDS );
	return $snapshot;
}

The lp_catalog_sync_stock() job of the cron page changes in one place: instead of its own wp_remote_get() and json_decode(), it loops over lp_catalog_get_distributor_stock( true ) as $isbn => $quantity, so the job always fetches a fresh list, refreshes the transient for every other caller and keeps the snapshot current. When the distributor is down, the job gets the snapshot back and rewrites the values the books already have. Design choices:

  • The stored value is never false: an empty array means "no stock data", and is_array() tells a hit from a miss.

  • lumen_catalog_fetch_stock() caches only good answers, so a recovery shows at the next call. The wrapper adds a deliberate negative cache: after a failure it caches the snapshot for five minutes, so a distributor outage does not turn every caller into another 10-second request.

  • The snapshot option is the only copy that must survive, so it is an option with false autoload, not a transient.

  • Each update_post_meta() fires updated_post_meta, which removes the card of that book from the object cache through the Cache class above.

  • Two simultaneous misses can both call the distributor. That is acceptable here; when it is not, take a short lock first with wp_cache_add( 'stock_lock', 1, 'lumen_catalog', 30 ), which only one caller wins on a persistent backend. On the default cache the lock exists only in the current request, so add_option() (which fails if the row exists) is the lock that works everywhere. HTTP timeouts, wp_safe_remote_get() and error handling are covered in HTTP and Filesystem APIs.

A must-use plugin answered https://distributor.example/ through the pre_http_request filter with two books, counted the calls, and answered 503 while an option was set. The check script deleted the transient, called lp_catalog_get_distributor_stock() three times and printed the number of HTTP calls, then forced a refresh with the distributor down and printed the result, and finally ran the lp_catalog_sync_stock action. Output, identical without and with the Redis drop-in:

HTTP calls after three reads: 1
Array
(
    [9781234567897] => 12
    [9781234567903] => 0
)
HTTP calls: 2
{"time":1793200573,"updated":2}

The failed refresh returned the snapshot instead of an empty list. With Redis, redis-cli ttl 'lumenpress:wp:transient:lumen_distributor_stock' returned 899 right after the job ran and the database held no transient row; without Redis, the value and timeout rows were both stored with autoload off.

Common errors and pitfalls

Symptom Cause and fix

A transient never expires and slows every page

It was set without expiration, so it is autoloaded. Give it an expiration

Hundreds of _transient_ rows left after enabling a drop-in

Rows written before the drop-in are no longer read. wp redis enable (Redis Object Cache 3.0.0) deleted them on the test site; with another drop-in, run wp transient delete --all before enabling it

A cached list stays stale after an import

The importer writes with SQL and fires no hooks. Call wp_cache_set_last_changed( 'lumen_catalog' ) after the import

wp cache flush empties other sites

It flushes the whole backend database. Use a key prefix per site, as in Performance and Caching

Security

Cache keys built from request data must not let one user read another’s data: include the user ID in the key, or use a non-persistent group, and never cache output that contains a nonce or private content in a shared group. Cached values are trusted only as much as their source: the distributor answer is sanitized before it is stored and escaped on output. Never unserialize() values from outside, and keep Redis private (Redis: Security).