How to use transients to cache heavy queries in PHP in WordPress

Contents

Introduction

Transients are WordPresss lightweight API for storing cached data temporarily. They are ideal for caching heavy queries, expensive calculations, and remote requests so your pages render faster and your database does less work. This article explains every important detail you need to safely and effectively use transients to cache heavy PHP/WordPress queries, with practical, production-ready code and strategies for invalidation, race-condition prevention, and background refresh.

When to use transients

  • Long-running WP_Query or complex joins that are executed on many page loads.
  • Expensive custom SQL using wpdb->get_results or aggregations.
  • Remote API calls (rate-limited or slow) — store results instead of calling each request.
  • Expensive calculations (large loops, heavy CPU work) whose results change infrequently.
  • Caching results for public pages or endpoints — avoid caching per-user data unless keyed by user ID.

How transients work (internals)

Transients are stored in the options table by default (as _transient_{key} and _transient_timeout_{key}). If a persistent object cache (Redis, Memcached, etc.) is configured, get_transient/set_transient will use it, keeping calls off the DB. In multisite, use site transients (set_site_transient/get_site_transient) which are stored network-wide.

WordPress automatically serializes arrays and objects stored in transients. Values that cannot be serialized (like closures) will fail. Expired transients are not immediately removed from the DB unless a cleanup process runs get_transient checks the timeout and returns false for expired items.

Basic Transients API

  • set_transient( key, value, expiration ) — store a value (expiration in seconds).
  • get_transient( key ) — retrieve a value, or false on miss/expired.
  • delete_transient( key ) — explicitly delete a transient.
  • set_site_transient / get_site_transient / delete_site_transient — multisite equivalents stored network-wide.

Function signuature examples

// set a transient valid for 1 hour
set_transient( my_key, value, HOUR_IN_SECONDS )

// get a transient
value = get_transient( my_key )

// delete a transient
delete_transient( my_key )

Key naming and uniqueness

Always namespace transient keys for your plugin/theme to avoid collisions. Keep keys short (some object cache backends limit key length). For cache entries that vary by parameters, make the key deterministic by hashing the query or relevant args (md5 or wp_hash).

prefix = myplugin_
args_hash = md5( wp_json_encode( query_args ) )
key = prefix . heavy_query_ . args_hash

Example 1 — Cache a heavy WP_Query result

Cache a complex WP_Query output that is expensive to run on every page load. Use a hash of the query args to create a stable key, and remember to call wp_reset_postdata() after using WP_Query.

function myplugin_get_expensive_posts( args = array() ) {
    defaults = array(
        post_type      => post,
        posts_per_page => 20,
        meta_key       => some_meta,
        // ... other expensive query args
    )
    args = wp_parse_args( args, defaults )

    key = myplugin_posts_ . md5( wp_json_encode( args ) )
    cached = get_transient( key )
    if ( false !== cached ) {
        return cached // cache hit
    }

    // cache miss — run the expensive query
    query = new WP_Query( args )
    posts = query->posts
    wp_reset_postdata()

    // Store the results in a transient for 12 hours
    set_transient( key, posts, 12  HOUR_IN_SECONDS )

    return posts
}

Example 2 — Cache a heavy custom SQL query using wpdb

When using wpdb->get_results for complex joins, always use prepared queries and cache the results. Hash the SQL or its args and avoid storing DB resource handles — store arrays of scalars/arrays/objects.

global wpdb
function myplugin_get_heavy_report( start_date, end_date ) {
    global wpdb
    args = array( start => start_date, end => end_date )
    key = myplugin_report_ . md5( wp_json_encode( args ) )
    cached = get_transient( key )
    if ( false !== cached ) {
        return cached
    }

    sql = wpdb->prepare(
        
        SELECT p.ID, p.post_title, COUNT(com.ID) AS comment_count
        FROM {wpdb->posts} p
        LEFT JOIN {wpdb->comments} com ON com.comment_post_ID = p.ID AND com.comment_date BETWEEN %s AND %s
        WHERE p.post_date BETWEEN %s AND %s
        GROUP BY p.ID
        ORDER BY comment_count DESC
        ,
        start_date, end_date, start_date, end_date
    )

    results = wpdb->get_results( sql )
    set_transient( key, results, DAY_IN_SECONDS ) // cache 24 hours
    return results
}

Example 3 — Cache results for a REST API endpoint

For REST endpoints that serve public data, using transients is an excellent way to reduce DB load and external calls. Use request parameters to generate the key, and ensure proper sanitization.

add_action( rest_api_init, function () {
    register_rest_route( myplugin/v1, /feed, array(
        methods  => GET,
        callback => myplugin_rest_get_feed,
        permission_callback => __return_true,
    ))
})

function myplugin_rest_get_feed( request ) {
    args = request->get_params()
    key = myplugin_rest_feed_ . md5( wp_json_encode( args ) )

    cached = get_transient( key )
    if ( false !== cached ) {
        return rest_ensure_response( cached )
    }

    // build data
    data = myplugin_build_feed( args )

    set_transient( key, data, 2  HOUR_IN_SECONDS )
    return rest_ensure_response( data )
}

Cache invalidation strategies (essential)

Invalidation is as important as caching. When underlying data changes, you must clear or update relevant transients so clients dont see stale content. Use actions that fire on content changes.

  • Posts: hook into save_post, delete_post, and transition_post_status.
  • Terms: use edit_terms, create_term, delete_term.
  • Users: profile_update, delete_user.
  • Options: update_option_{option_name}.
  • Plugin/theme activation: refresh caches at activation time.
function myplugin_clear_cache_on_save( post_id, post, update ) {
    if ( wp_is_post_revision( post_id ) ) {
        return
    }
    // namespace and pattern-based deletion: delete known keys or compute keys
    delete_transient( myplugin_posts_overview ) // example
    // If using hashed keys, store a list of keys or use a versioning key (see below).
}
add_action( save_post, myplugin_clear_cache_on_save, 10, 3 )

Recommended: use a versioning (group) key for easy invalidation

Instead of tracking and deleting many hashed keys, store a small version number transient and include it in the cache key. When content changes, bump the version all derived keys will be considered stale immediately.

function myplugin_cache_version() {
    key = myplugin_cache_version
    v = get_transient( key )
    if ( false === v ) {
        v = time() // initial version
        set_transient( key, v, MONTH_IN_SECONDS )
    }
    return v
}

function myplugin_get_key_with_version( base ) {
    return base . _ . myplugin_cache_version()
}

function myplugin_bump_cache_version() {
    set_transient( myplugin_cache_version, time(), MONTH_IN_SECONDS )
}
add_action( save_post, myplugin_bump_cache_version )

Preventing cache stampedes (race conditions)

If many processes see an expired transient and try to regenerate it at once, you can overload your origin. Use locking, stale-while-revalidate, or background regeneration techniques to avoid a dogpile problem.

Simple lock pattern (blocking single generator)

function myplugin_get_with_lock( key, generate_callback, expire = HOUR_IN_SECONDS ) {
    cached = get_transient( key )
    if ( false !== cached ) {
        return cached
    }

    // lock key short-lived to prevent multiple regenerations
    lock_key = key . _lock
    if ( get_transient( lock_key ) ) {
        // Another process is regenerating. Return false or stale data fallback.
        return false
    }

    // Obtain lock
    set_transient( lock_key, 1, 30 ) // 30-second lock

    // Regenerate
    data = call_user_func( generate_callback )

    // Store result and release lock
    set_transient( key, data, expire )
    delete_transient( lock_key )

    return data
}

Stale-while-revalidate pattern (serve stale, refresh in background)

Store two transients: one for the actual data and one for a short refresh until timestamp. If data is stale but still within a grace window, serve the stale data and trigger asynchronous regeneration with wp_remote_post to an admin-ajax or REST endpoint that refreshes the transient.

function myplugin_get_with_stale_revalidate( key, generate_callback, ttl = HOUR_IN_SECONDS, grace = 300 ) {
    data = get_transient( key )
    expires_at = get_transient( key . _expires )

    if ( false !== data  expires_at  time() < expires_at ) {
        return data // fresh
    }

    if ( false !== data  expires_at  time() < ( expires_at   grace ) ) {
        // stale but inside grace window: serve stale and refresh in background
        myplugin_trigger_background_refresh( key, generate_callback, ttl )
        return data
    }

    // expired or missing: regenerate synchronously
    data = call_user_func( generate_callback )
    set_transient( key, data, ttl )
    set_transient( key . _expires, time()   ttl, ttl   grace )
    return data
}

function myplugin_trigger_background_refresh( key, generate_callback, ttl ) {
    // Call an internal endpoint via wp_remote_post - non-blocking
    url = admin_url( admin-ajax.php?action=myplugin_refresh_transientkey= . rawurlencode( key ) )
    wp_remote_post( url, array( timeout => 0.01, blocking => false ) )
}

add_action( wp_ajax_nopriv_myplugin_refresh_transient, myplugin_ajax_refresh )
add_action( wp_ajax_myplugin_refresh_transient, myplugin_ajax_refresh )

function myplugin_ajax_refresh() {
    key = isset( _GET[key] ) ? sanitize_text_field( wp_unslash( _GET[key] ) ) : 
    if ( empty( key ) ) {
        wp_die()
    }
    // Rebuild data inside this request, careful about permissions and cost
    data = my_rebuild_for_key( key )
    if ( data ) {
        set_transient( key, data, HOUR_IN_SECONDS )
        set_transient( key . _expires, time()   HOUR_IN_SECONDS, HOUR_IN_SECONDS   300 )
    }
    wp_die()
}

Using site transients on multisite

Use set_site_transient/get_site_transient for data that should be shared across the network. These are stored in sitemeta and not per-site options.

// network-wide cache
network_cache = get_site_transient( myplugin_network_stats )
if ( false === network_cache ) {
    network_cache = myplugin_build_network_stats()
    set_site_transient( myplugin_network_stats, network_cache, 6  HOUR_IN_SECONDS )
}

Integration with persistent object cache

If a persistent object cache (Redis, Memcached) is configured, WordPress will use it for transients, giving much faster get/set times and reducing DB writes. However, remember that if object cache is not persistent or misconfigured, transients fall back to options table. When using object cache directly (wp_cache_get/wp_cache_set), you get even faster performance but lose the expiry management that transients provide (unless you implement it yourself).

Debugging and measuring

  • Use the Query Monitor plugin or enable SAVEQUERIES to see how often queries run.
  • Log time before and after heavy queries to verify savings.
  • Check the options table or object cache to inspect transient keys (look for _transient_ prefix).

Best practices

  1. Namespace keys: Prefix keys with your plugin/theme slug.
  2. Use hashed args: Hash query args with md5 or wp_json_encode md5 to ensure reproducible keys that are safe for caches with length limits.
  3. Reasonable TTL: Set TTL appropriate to how often data changes. Avoid centuries-long expirations for dynamic content.
  4. Invalidate properly: Hook into save/delete actions or use a versioning key to invalidate effectively.
  5. Be careful with logged-in users: Either avoid caching per-user private data or include user_id or capability in the cache key.
  6. Avoid storing non-serializable values: Don’t store DB resources or closures.
  7. Watch for key length: Some backends have limits keep keys reasonably short.
  8. Use stale-while-revalidate or locking for heavy recomputations: Prevent stampedes.
  9. Measure before and after: Confirm caching yields meaningful performance improvements.

Common pitfalls

  • Assuming transients are automatically autoloaded: they are not autoloaded like some options. Transients use their own option rows.
  • Not invalidating transients after updates, causing stale content.
  • Relying on transients as a security boundary. They can be cleared or manipulated if attackers can update options.
  • Storing extremely large data blobs that may exceed DB packet size or cause memory issues. Keep caches reasonably sized.
  • Storing objects that reference closures or internal resources — serialization will fail.

Advanced: layering object cache with transients

You can combine object cache (wp_cache_get/wp_cache_set) for fastest in-memory retrieval with transients for persistent fallback. Example flow: check wp_cache_get if miss, check get_transient if miss, generate and set both. This keeps repeated calls within the same request cheap and still persists between requests.

function myplugin_get_layered_cache( key, generate_callback, expire = HOUR_IN_SECONDS ) {
    cache_group = myplugin
    local = wp_cache_get( key, cache_group )
    if ( false !== local ) {
        return local
    }

    trans = get_transient( key )
    if ( false !== trans ) {
        // warm local cache
        wp_cache_set( key, trans, cache_group, expire )
        return trans
    }

    // regenerate
    data = call_user_func( generate_callback )
    set_transient( key, data, expire )
    wp_cache_set( key, data, cache_group, expire )
    return data
}

Security considerations

  • Sanitize any inputs used to build keys or queries.
  • Do not cache per-user sensitive data unless the cache key includes a secure user-specific token and access control checks are preserved.
  • If you expose cache-control or ETag headers, ensure transient contents are appropriate for public caching.

Measuring the impact

Always measure before and after adding transients. Look at average response time, DB query time, number of queries, and CPU. Common tools: Query Monitor, New Relic, or simple microtime logging around your function calls.

Summary

Transients are a powerful, built-in facility for caching heavy queries in WordPress. They reduce database load and speed up responses when used correctly. Key points:

  • Use deterministic, namespaced keys (hash query args).
  • Choose a TTL appropriate to the data volatility.
  • Implement invalidation via hooks or versioning.
  • Prevent cache stampedes with locks or stale-while-revalidate.
  • Combine with persistent object caches for best performance.

For full API details see the WordPress developer reference: https://developer.wordpress.org/apis/handbook/transients/



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *