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()returnsfalseon a miss, so a storedfalsecan’t be told apart from a miss and your code recomputes every time. An emptyget_posts()result has a similar problem if you check it withif ( ! $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()returnsfalse. - 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
<?php
/**
* dpc_remember(): a transient helper that also caches false, 0, '' and [].
*/
/**
* 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 );
}
Add it with Scripts Organizer
In Scripts Organizer go to Code Blocks → Add New and give the block a name. Set Trigger location to Everywhere, tick PHP under Script location, paste the code into the PHP editor and publish. Leave Action/Hook empty. Everywhere loads the helper on every request, front end, admin, REST and cron, so your theme and other snippets can call it from their hooks.
Prefer a file? Put the same code in an mu-plugin (wp-content/mu-plugins/dpc-remember.php) or in your theme’s functions.php.
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. Put it in your theme or in a second code block.
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 stillfalse, but a hit is always an array that has avkey. That way a cachedfalseor 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_Errorresults are returned but not stored, so the next request tries again.
Gotchas
- Call it from a hook. Scripts Organizer loads its code blocks while plugins load, so another plugin’s file, or an mu-plugin, that calls
dpc_remember()at the top level runs before the helper exists. Call it oninitor later, which is where real caching code runs anyway. If an mu-plugin needs it earlier, keep the helper in an mu-plugin file instead. - 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_optionskeeps 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.
In the Scripts Organizer library Import it with one click from Scripts Organizer.