WordPress set_transient Not Working? Causes and a Fix

You wrapped a slow query in set_transient() to speed up a slow WordPress page, yet Query Monitor still shows the query on every load. The usual culprit isn’t the Transients API. It’s one of a few edge cases that make get_transient() look like a miss even after the value was saved.

The snippet below is a small dpc_remember() helper that avoids those edge cases. You give it a key, a lifetime and a callback. The callback runs once and its result is reused until it expires or you clear it. It also caches false, 0, '' and empty arrays, which a plain transient can’t do reliably.

Why set_transient seems not to work

  • You cached false. get_transient() returns false on a miss, so a stored false can’t be told apart from a miss and your code recomputes every time. An empty get_posts() result has a similar problem if you check it with if ( ! $cached ).
  • The key is too long. Transient names are limited to 172 characters. If you build a key from query args or a URL, it can go over that limit and set_transient() returns false.
  • The expiration is 0. With no expiry, the transient never expires and its option is autoloaded on every request (unless an object cache is active). On a big site that makes every page slower.
  • You cached a WP_Error. A failed API call or query gets stored and served for an hour.

The snippet

Put this in an mu-plugin (wp-content/mu-plugins/dpc-remember.php) so other plugins and your theme can call it, or in your theme’s functions.php.

<?php
/**
 * Plugin Name: DPC Remember (transient helper)
 */

/**
 * Return a cached value, or compute it once and cache it for $ttl seconds.
 */
function dpc_remember( string $key, int $ttl, callable $callback ): mixed {
	$name   = dpc_transient_name( $key );
	$cached = get_transient( $name );

	// Values are wrapped, so false, 0, '' and [] are real hits.
	if ( is_array( $cached ) && array_key_exists( 'v', $cached ) ) {
		return $cached['v'];
	}

	$value = $callback();

	// Never cache failures.
	if ( is_wp_error( $value ) ) {
		return $value;
	}

	// A TTL of 0 means "never expires" and autoloads the option: refuse that.
	set_transient( $name, array( 'v' => $value ), max( MINUTE_IN_SECONDS, $ttl ) );

	return $value;
}

/**
 * Delete a value stored with dpc_remember().
 */
function dpc_forget( string $key ): bool {
	return delete_transient( dpc_transient_name( $key ) );
}

/**
 * Hash the key so it never exceeds the 172-character transient limit.
 */
function dpc_transient_name( string $key ): string {
	return 'dpc_' . md5( $key );
}

Using it

Here is a real example: the IDs of the five newest posts, cached for an hour and cleared as soon as a post is saved, so visitors never see a stale list.

function dpc_get_recent_post_ids(): array {
	return dpc_remember(
		'recent_post_ids',
		HOUR_IN_SECONDS,
		static function (): array {
			return get_posts(
				array(
					'post_type'     => 'post',
					'post_status'   => 'publish',
					'numberposts'   => 5,
					'fields'        => 'ids',
					'no_found_rows' => true,
				)
			);
		}
	);
}

add_action( 'save_post_post', 'dpc_flush_recent_post_ids' );
add_action( 'deleted_post', 'dpc_flush_recent_post_ids' );

function dpc_flush_recent_post_ids(): void {
	dpc_forget( 'recent_post_ids' );
}

How it works

  • The value is stored as array( 'v' => $value ). A miss is still false, but a hit is always an array that has a v key. That way a cached false or empty array counts as a hit.
  • dpc_transient_name() hashes your key into a fixed 36-character name. You can pass a long key like 'report_' . wp_json_encode( $args ).
  • The lifetime is never less than a minute, so you can’t accidentally create a never-expiring, autoloaded transient.
  • WP_Error results are returned but not stored, so the next request tries again.

Gotchas

  • With a persistent object cache (Redis, Memcached), transients never reach wp_options. They live in the cache and can be evicted early. Treat a transient as “may disappear any time”, never as storage.
  • Clear on change, don’t wait for expiry. Hook dpc_forget() to the event that makes the data stale (save_post_{type}, edited_term, updated_option), as in the example.
  • Cache stampede. When a popular key expires, several requests can run the callback at the same moment. For very expensive callbacks, refresh the value from WP-Cron before it expires so visitors never trigger it.
  • Expired transients. Without an object cache, WordPress removes expired transients from the database once a day via cron. If WP-Cron is disabled, set up a real cron job, or wp_options keeps growing.

Want to check which queries are still slow after caching? The $wpdb helpers in WPDB Object good to know methods help, and if the admin is slow rather than the front end, look at the Heartbeat API first.

Prefer to manage snippets like this from the dashboard instead of an mu-plugin file? Scripts Organizer lets you add PHP hooks from a code editor inside WordPress.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You’ll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You'll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

Click to Copy