How to cache HTML per URL with transients and unique keys in WordPress

Contents

Introduction

This article explains, in full detail, how to cache full HTML per URL in WordPress using transients and unique keys. The approach stores rendered HTML (the complete response) in a transient keyed by a unique string derived from the requested URL and optional variations (language, device, user role, cart state, etc.). It covers generation of robust keys, where and how to intercept requests, how to capture and save output, invalidation strategies, caveats, and production considerations.

Why cache HTML per URL with transients?

  • Speed: serving pre-rendered HTML bypasses WP query, template, plugins and PHP work for that request.
  • Simple TTL persistence: transients combine optional expiration with compatibility with persistent object caches (Redis, Memcached) when available.
  • Granular control: you can vary cached HTML per-URL and per-variation (language, device, user role, query params) using unique keys.
  • Safe fallback: when object cache is not present, transients fall back to options and still work.

When NOT to cache HTML

  • Signed-in user pages that include personalized content (admin bar, per-user data).
  • Pages used by forms, nonces, or short-lived tokens.
  • WP-admin, REST API, AJAX endpoints, previews, feeds (these often require dynamic output).
  • Pages that must reflect real-time user-specific data (shopping cart unless you vary by cart contents).

High-level implementation overview

  1. Generate a deterministic canonical URL string for the request and compute a unique key using a stable hash.
  2. Decide what request attributes should cause separate cached outputs (variations) and include them in the key.
  3. At the earliest practical frontend hook, check for a transient with that key. If present, echo it and exit.
  4. If absent, start output buffering, let WordPress render, then save the final buffer to a transient on shutdown (or via an ob callback).
  5. Provide invalidation hooks (save_post, edit, delete) and tools (admin button, CLI command) to purge or update related transients.

Key generation: canonical URL variation signature

A good key is deterministic, collision-resistant, and includes any context that should create separate cached outputs (e.g., language, desktop/mobile, cart hash). Always normalize the URL (scheme, host, path, sorted query parameters when desired) before hashing. Use an explicit prefix to make queries easy.

Example: key generation function


Core caching layer: serving and capturing HTML

Put the serving logic as early as possible in the request lifecycle so you skip unnecessary work. template_redirect is a common hook used for front-end template rendering. For most sites, avoid caching for previews, feeds, REST API, and users with cookies implying personalization.

Simple implementation: check transient, serve if present, else buffer and save


Notes about buffering and ob callbacks

  • You can also use ob_start with an output callback to intercept the final buffer and persist it. The shutdown approach is robust because it runs after rendering but before PHP exits.
  • Closures are fine if your PHP version supports them otherwise use named functions and global variables.

Invalidation strategies

Caching without a coherent invalidation strategy is risky. Typical patterns:

  • Time-based TTL: transient expiration ensures eventual consistency.
  • Event-based purge: delete cache entries when content changes (save_post, delete_post, transition_post_status).
  • Tag/index-based mapping: maintain an index that maps post IDs or taxonomy terms to transient keys so you can delete all keys related to a changed post or term.
  • Manual triggers: admin button, WP-CLI command, or a webhook.

Record keys for the current post so they can be purged when post changes

 [transient_keys]
  so when a post is updated we can delete affected cached pages.
 /

function html_cache_record_key_for_current_post( key ) {
    // Best-effort: determine main queried object (post)
    if ( ! is_singular()  ! is_single()  ! is_page() ) {
        return
    }

    global post
    if ( ! isset( post->ID ) ) {
        return
    }

    post_id = post->ID
    index   = get_option( html_cache_index, array() )

    if ( empty( index[ post_id ] )  ! in_array( key, index[ post_id ], true ) ) {
        index[ post_id ][] = key
        update_option( html_cache_index, index )
    }
}

/
  Clear cached transients recorded for a post
 /
function html_cache_purge_post( post_id ) {
    index = get_option( html_cache_index, array() )

    if ( ! empty( index[ post_id ] )  is_array( index[ post_id ] ) ) {
        foreach ( index[ post_id ] as key ) {
            delete_transient( key )
        }
        unset( index[ post_id ] )
        update_option( html_cache_index, index )
    }
}

/
  Hook purge on post updates, trash, deletion and status transitions
 /
add_action( save_post, html_cache_purge_post, 10, 1 )
add_action( deleted_post, html_cache_purge_post, 10, 1 )
add_action( trash_post, html_cache_purge_post, 10, 1 )
add_action( transition_post_status, function( new_status, old_status, post ) {
    // Purge when draft -> publish, publish -> draft, etc.
    html_cache_purge_post( post->ID )
}, 10, 3 )
?>

Clearing everything (advanced)

If you need a way to clear all HTML cache entries, one method is to track keys in an index (like html_cache_index) and delete them. Another approach is to search transients in the options table that match your prefix (if your site uses DB-persisted transients). The direct DB method is more invasive and should be used with care.

esc_like( _transient_html_cache_ ) . %
    rows = wpdb->get_col( wpdb->prepare(
        SELECT option_name FROM {wpdb->options} WHERE option_name LIKE %s,
        like
    ) )

    if ( ! empty( rows ) ) {
        foreach ( rows as opt ) {
            // If option is _transient_html_cache_xxx then delete_option will remove it
            delete_option( str_replace( _transient_, , opt ) )
        }
    }

    // You might also have persistent caches to flush (Redis / memcached) via wp_cache_flush() if necessary.
}
?>

Advanced variations and rules

  • Vary by cookie: include specific cookie values in key if pages should differ by cookie. Example: cart hash for e-commerce.
  • Vary by role: if you want different output for editors vs subscribers, include current_user_role in key (but be careful about caching personal content).
  • Vary by query params: include whitelisted query args drop hopelessly-growing args like session ids.
  • Edge-side vs server-side: this transient-based approach caches at the WordPress server layer. If you have a CDN, you can combine server-side HTML caching with proper Cache-Control headers and allow CDN caching.

Example: adding custom variations

roles
        role = ! empty( roles ) ? implode( ,, roles ) : user
    }

    return key . _ . md5( role: . role )
}
?>

Practical considerations and gotchas

  1. Logged-in users: default to skipping cache for logged-in users unless you explicitly segregate by user id/role.
  2. Non-deterministic output: shortcodes, widgets, or plugins that rely on transient state or global counters may produce inconsistent results when cached. Test thoroughly.
  3. Size limits: extremely large HTML pages will be serialized into transients and stored in option rows if no object cache is present. This can bloat the options table. Consider splitting or using a persistent object cache.
  4. Persistent object cache: using Redis or Memcached is strongly recommended for production to avoid writing large options to the DB.
  5. Cache headers: you can set HTTP headers (Cache-Control, ETag, Last-Modified) for downstream caches, but be careful not to expose personalized content to public caches.
  6. Concurrency: race conditions can happen when many concurrent misses try to regenerate the same page. Consider a lock mechanism (set_transient(lock_key, 1, short_ttl)) to prevent stampeding.

Example: simple stampede protection


Testing and verification

  1. Open a page while not logged in. Confirm header X-Cache: MISS on first load and X-Cache: HIT on subsequent loads.
  2. Clear cache and confirm page updates (edit post, save, then check cache is purged and shows updated content).
  3. Test variations (mobile / desktop, language switches, query params) to ensure independent caches per variant.
  4. Test concurrent requests to avoid stampede simulate multiple simultaneous requests on a MISS and ensure no catastrophic errors occur.
  5. Monitor options table size if no persistent cache is installed.

Performance and storage recommendations

  • Use a persistent object cache (Redis, Memcached) in production for transient storage. This prevents bloating the wp_options table and is significantly faster.
  • Keep TTLs reasonably short for frequently-updated sites for mostly static sites use longer TTLs (hours or days).
  • Avoid caching pages that include many per-user scripts or tokens. Instead, consider Edge Side Includes (ESI) or AJAX for small dynamic fragments.

Example: admin clear button (simple)

Add a small admin page or admin bar button that calls html_cache_clear_all(). This example adds a dashboard widget with a clear button. In production, protect actions with capabilities and nonces.

HTML cache cleared.

} echo
wp_nonce_field( clear_html_cache_action, clear_html_cache_nonce ) echo

echo
} ) } ) ?>

Security considerations

  • Never cache responses that include private or secret information. If in doubt, skip caching for signed-in users.
  • Protect admin cache-clearing endpoints with capability checks and nonces.
  • Be careful when including cookies in cache keys—ensure the consumed cookie values are safe to use in a cache key and wont leak sensitive info.

Recap: checklist before enabling HTML-per-URL caching

  • Decide target pages and exceptions (admin, REST, AJAX, previews).
  • Build a robust canonical URL routine and define variation rules.
  • Choose TTL values and consider persistent object cache for production.
  • Implement invalidation hooks (save_post, delete_post, taxonomy changes).
  • Test extensively (logged-in vs guest, variations, concurrency, cache purge).

Further reading and tools

  • WordPress functions: get_transient, set_transient, delete_transient.
  • Persistent object cache drop-in documentation: Object Cache.
  • Consider full-page caching plugins (WP Super Cache, WP Rocket, FastCGI cache via server/CDN) for battle-tested production solutions if you prefer not to DIY.


Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

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