Object caching is one of the most powerful performance optimization techniques available to WordPress plugin developers, but it's also one of the most misunderstood. Many developers skip caching entirely because it seems complex, while others implement naive caching strategies that provide minimal benefit or even create subtle bugs.
The WordPress object cache API is straightforward once you understand the core concepts. But implementing effective caching requires thinking carefully about cache groups, invalidation strategies, and the difference between persistent and non-persistent caching. This guide walks you through everything you need to know to build fast, cache-aware plugins.
Whether you're reducing database queries in a complex plugin, improving REST API performance, or building features that process large datasets, object caching can dramatically improve your plugin's performance. The key is understanding when to cache, what to cache, and how to invalidate cached data reliably.
Table of Contents
- Understanding WordPress Object Cache
- Using wp_cache_get and wp_cache_set
- Cache Groups and Organization
- Cache Invalidation Strategies
- Persistent Cache: Redis and Memcached
- Common Caching Mistakes
- Testing Cache Effectiveness
- Frequently Asked Questions
Understanding WordPress Object Cache
The WordPress object cache is an in-memory key-value store for temporary data. By default, WordPress stores cached objects in memory for the duration of the current page load. If you need caching that persists across page loads, you configure a persistent cache backend like Redis or Memcached.
The fundamental principle is simple: data that's expensive to compute or retrieve should be cached so you don't have to compute or retrieve it repeatedly. This applies to database queries, API calls, complex calculations, and any operation that consumes significant time or resources.
The power of object caching lies in its ability to dramatically reduce the work required to serve a page. A page that without caching requires 50 database queries might need only 5 queries with caching. The cached results are returned instantly from memory, while uncached data is computed once and reused. For high-traffic sites, this difference can be the difference between manageable server load and crashed servers.
Consider a plugin that displays product reviews. For each product, it might need to:
- Query the database for all reviews
- Calculate average rating
- Count total reviews
- Fetch reviewer information
Without caching, this happens for every page load. With caching, you compute it once and reuse the result hundreds of times until it needs to be invalidated.
// Without caching - slow
function get_product_review_stats( $product_id ) {
$reviews = get_posts( array(
'post_type' => 'review',
'meta_key' => 'product_id',
'meta_value' => $product_id,
) );
$total = count( $reviews );
$sum_rating = 0;
foreach ( $reviews as $review ) {
$sum_rating += intval( get_post_meta( $review->ID, 'rating', true ) );
}
$average_rating = $total > 0 ? $sum_rating / $total : 0;
return array(
'total' => $total,
'average' => $average_rating,
);
}
// With caching - fast
function get_product_review_stats( $product_id ) {
$cache_key = 'product_review_stats_' . $product_id;
$cache_group = 'product_reviews';
// Check cache first
$cached = wp_cache_get( $cache_key, $cache_group );
if ( false !== $cached ) {
return $cached;
}
// Not cached - compute
$reviews = get_posts( array(
'post_type' => 'review',
'meta_key' => 'product_id',
'meta_value' => $product_id,
) );
$total = count( $reviews );
$sum_rating = 0;
foreach ( $reviews as $review ) {
$sum_rating += intval( get_post_meta( $review->ID, 'rating', true ) );
}
$average_rating = $total > 0 ? $sum_rating / $total : 0;
$result = array(
'total' => $total,
'average' => $average_rating,
);
// Cache for 1 hour (3600 seconds)
wp_cache_set( $cache_key, $result, $cache_group, 3600 );
return $result;
}
The second version checks the cache first, avoiding expensive computation in most cases. Only on cache miss (when the data isn't cached or has expired) does it compute the result.
By default, WordPress only keeps object cache in memory during the current page load. When the request ends, the cache is discarded. This is fine for most cases—you still save repeated queries within the same page load. But if you want caching to persist across multiple page loads, you need a persistent cache backend.
Using wp_cache_get and wp_cache_set
The WordPress object cache API consists of four main functions:
wp_cache_get(): Retrieve a value from the cachewp_cache_set(): Store a value in the cachewp_cache_delete(): Remove a value from the cachewp_cache_flush(): Clear all cache entries
Let me show you the correct usage patterns:
// Basic usage
$value = wp_cache_get( 'my_key', 'my_group' );
if ( false === $value ) {
// Not in cache - compute the value
$value = expensive_computation();
// Store in cache for future use
wp_cache_set( 'my_key', $value, 'my_group' );
}
return $value;
The critical point here is checking for false (not !$value). A cached value might be falsy (0, empty string, empty array) but still valid. Using false === correctly distinguishes between "not in cache" and "in cache with a falsy value".
The second parameter to both functions is the cache group. Cache groups are crucial for organization and invalidation. Instead of having thousands of individual cache keys competing for memory, you group related entries. This makes invalidation simpler and code more readable.
// Better organization with groups
$user_cache = wp_cache_get( 'user_123', 'user_profiles' );
$post_cache = wp_cache_get( 'post_456', 'post_data' );
$comment_cache = wp_cache_get( 'comment_789', 'comment_stats' );
// When user updates, invalidate entire user group
wp_cache_delete( 'user_123', 'user_profiles' );
The optional fourth parameter to wp_cache_set() is the expiration time in seconds:
// Cache for 1 hour
wp_cache_set( 'key', 'value', 'group', 3600 );
// Cache indefinitely (only expires if manually deleted or cache flushed)
wp_cache_set( 'key', 'value', 'group', 0 );
// Default: no expiration
wp_cache_set( 'key', 'value', 'group' );
For non-persistent cache (the default), expiration times don't matter much since everything expires at the end of the page load anyway. But for persistent cache backends like Redis, expiration times are critical for preventing the cache from growing infinitely.
Cache Groups and Organization
Effective cache group strategy is essential for building maintainable, performant plugins. A good cache group strategy makes invalidation simple and prevents cache key collisions.
// Well-organized cache groups
class Product_Cache {
const CACHE_GROUP = 'product_data';
public static function get_by_id( $product_id ) {
$cache_key = 'product_' . $product_id;
$cached = wp_cache_get( $cache_key, self::CACHE_GROUP );
if ( false !== $cached ) {
return $cached;
}
$product = fetch_product( $product_id );
wp_cache_set( $cache_key, $product, self::CACHE_GROUP, 3600 );
return $product;
}
public static function invalidate( $product_id ) {
$cache_key = 'product_' . $product_id;
wp_cache_delete( $cache_key, self::CACHE_GROUP );
}
public static function invalidate_all() {
wp_cache_flush_group( self::CACHE_GROUP );
}
}
// Usage
$product = Product_Cache::get_by_id( 123 );
Product_Cache::invalidate( 123 ); // Clear specific product
Product_Cache::invalidate_all(); // Clear all product cache
This pattern encapsulates cache logic and makes it easy to find where a particular cache key is used. It also makes invalidation explicit and discoverable.
Hierarchical cache groups help organize related data:
// Hierarchical structure
class Cache_Groups {
const USER = 'user';
const USER_PROFILE = 'user_profile';
const USER_PREFERENCES = 'user_preferences';
const POST = 'post';
const POST_META = 'post_meta';
const COMMENT = 'comment';
}
// Now you can invalidate at different levels
wp_cache_delete( "profile_$user_id", Cache_Groups::USER_PROFILE );
wp_cache_delete( "pref_$user_id", Cache_Groups::USER_PREFERENCES );
// Or use a helper to clear all user-related caches
function invalidate_user_caches( $user_id ) {
wp_cache_delete( "profile_$user_id", Cache_Groups::USER_PROFILE );
wp_cache_delete( "pref_$user_id", Cache_Groups::USER_PREFERENCES );
wp_cache_delete( "stats_$user_id", Cache_Groups::USER );
}
Cache Invalidation Strategies
Here's where most developers struggle: when should you invalidate cache? If you invalidate too eagerly, you lose the benefit of caching. If you invalidate too late, users see stale data.
The best strategy depends on your data and use case. Let me show several patterns:
Time-based expiration is simplest: cache expires after a certain time regardless of whether the data changed.
// Time-based: cache for 1 hour, then recompute
$data = wp_cache_get( 'expensive_report', 'reports' );
if ( false === $data ) {
$data = compute_expensive_report();
wp_cache_set( 'expensive_report', $data, 'reports', 3600 ); // 1 hour
}
return $data;
This works well for data that changes infrequently. But if the data changes every hour, users might see stale data for up to an hour.
Event-based invalidation clears cache when specific events occur.
// Invalidate cache when product is updated
add_action( 'woocommerce_update_product', function( $product_id ) {
Product_Cache::invalidate( $product_id );
} );
add_action( 'woocommerce_product_set_stock', function( $product ) {
Product_Cache::invalidate( $product->get_id() );
} );
This ensures users always see fresh data, but requires carefully tracking all events that affect cached data.
Hybrid approach combines both strategies:
// Cache for 1 hour, but also invalidate on update
wp_cache_set( $cache_key, $data, $cache_group, 3600 );
add_action( 'product_updated', function( $product_id ) {
if ( $product_id === current_product_id() ) {
wp_cache_delete( $cache_key, $cache_group );
}
} );
This provides freshness when data changes frequently, but doesn't lose the benefit of caching for data that rarely changes.
For complex invalidation, you might use cache tagging:
// Cache tagging for complex invalidation
class Tagged_Cache {
private static $tag_to_keys = array();
public static function set_with_tags( $key, $value, $group, $tags = array(), $expire = 0 ) {
wp_cache_set( $key, $value, $group, $expire );
foreach ( $tags as $tag ) {
if ( ! isset( self::$tag_to_keys[ $tag ] ) ) {
self::$tag_to_keys[ $tag ] = array();
}
self::$tag_to_keys[ $tag ][] = array(
'key' => $key,
'group' => $group,
);
}
}
public static function invalidate_by_tag( $tag ) {
if ( isset( self::$tag_to_keys[ $tag ] ) ) {
foreach ( self::$tag_to_keys[ $tag ] as $item ) {
wp_cache_delete( $item['key'], $item['group'] );
}
unset( self::$tag_to_keys[ $tag ] );
}
}
}
// Usage: Cache product with tags for category and featured status
Tagged_Cache::set_with_tags(
'product_123',
$product_data,
'products',
array( 'category_5', 'featured' ),
3600
);
// Invalidate all products in a category
Tagged_Cache::invalidate_by_tag( 'category_5' );
Persistent Cache: Redis and Memcached
By default, WordPress object cache only persists within a single page load. If you want caching to span multiple page loads and requests, you need a persistent cache backend.
WordPress supports two main persistent backends: Redis and Memcached. Both are fast, in-memory stores that can be accessed by multiple server processes.
To use persistent cache, you need to install a cache backend plugin and configure WordPress to use it. Popular options include:
- WP Redis (uses Redis)
- WP Memcached (uses Memcached)
- Batcache (adds HTTP caching on top of object cache)
Once a persistent cache is configured, wp_cache_get() and wp_cache_set() automatically use it. Your code doesn't change—the benefit is that cache persists across requests.
// Same code, but now caches persist across page loads
function get_product_data( $product_id ) {
$cache_key = 'product_' . $product_id;
$cached = wp_cache_get( $cache_key, 'products' );
if ( false !== $cached ) {
return $cached; // Reused across multiple page loads
}
$product = fetch_product( $product_id );
// Cache persists in Redis/Memcached, not just in-memory
wp_cache_set( $cache_key, $product, 'products', 3600 );
return $product;
}
Redis is generally preferred because it supports more data types and operations. When configuring Redis for WordPress, use the WP_REDIS_HOST, WP_REDIS_PORT, and other constants in wp-config.php:
// wp-config.php
define( 'WP_REDIS_HOST', 'redis.example.com' );
define( 'WP_REDIS_PORT', 6379 );
define( 'WP_REDIS_PASSWORD', 'your_redis_password' );
define( 'WP_REDIS_DB', 0 );
define( 'WP_REDIS_TIMEOUT', 1 );
define( 'WP_REDIS_READ_TIMEOUT', 1 );
When using persistent cache, pay special attention to cache expiration. Redis has a maximum number of connections and maximum memory. If you cache too aggressively without expiration, Redis can run out of memory or connections:
// Always set expiration for persistent cache
wp_cache_set( $key, $value, $group, 3600 ); // 1 hour expiration
// Not setting expiration means it persists forever
wp_cache_set( $key, $value, $group, 0 ); // No expiration - risky!
You can monitor cache usage with Redis commands:
// Monitor Redis cache
// Connect to Redis and check memory usage
redis-cli
> info memory
> keys pattern:*
> ttl key_name
Common Caching Mistakes
Let me walk through the mistakes I see most often in production WordPress code:
Mistake 1: Checking for falsy values instead of false
// Wrong - returns false for any falsy value
$value = wp_cache_get( 'counter' );
if ( ! $value ) { // Fails if counter is 0!
$value = compute_counter();
wp_cache_set( 'counter', $value, 'group' );
}
// Correct - properly checks for cache miss
$value = wp_cache_get( 'counter' );
if ( false === $value ) { // Only true on cache miss
$value = compute_counter();
wp_cache_set( 'counter', $value, 'group' );
}
This is a subtle bug that works until someone tries to cache the value 0, empty string, empty array, or false.
Mistake 2: Not using cache groups
// Bad: All keys in global namespace
wp_cache_set( 'user_123', $data );
wp_cache_set( 'post_456', $data );
wp_cache_set( 'comment_789', $data );
// Good: Organized with groups
wp_cache_set( 'user_123', $data, 'users' );
wp_cache_set( 'post_456', $data, 'posts' );
wp_cache_set( 'comment_789', $data, 'comments' );
Without groups, cache keys can collide, and you lose the ability to bulk-invalidate related entries.
Mistake 3: Caching objects without considering serialization
// Problematic: Caching large complex objects
$large_object = fetch_large_data(); // 10MB object
wp_cache_set( 'large_data', $large_object, 'group' );
// This works but caches the entire object in memory.
// If you only need parts of it, cache those instead:
wp_cache_set( 'large_data_id', $large_object->id, 'group' );
wp_cache_set( 'large_data_name', $large_object->name, 'group' );
Cache what you actually need, not entire objects.
Mistake 4: Forgetting to invalidate on all update paths
// You invalidate on post update
add_action( 'save_post', function( $post_id ) {
wp_cache_delete( 'post_data_' . $post_id, 'posts' );
} );
// But forget to invalidate when posts are bulk-edited, imported, or updated via REST API
// Result: cache returns stale data when users use other update methods
Track all code paths that modify data and invalidate cache in each one.
Mistake 5: Over-aggressive caching
// Wrong: Cache query results indefinitely
$results = wp_cache_get( 'all_products' );
if ( false === $results ) {
$results = get_posts( array( 'post_type' => 'product' ) );
wp_cache_set( 'all_products', $results, 'products', 0 ); // Never expires!
}
// If someone adds a product, this cache won't update until manually cleared
// Better: Either invalidate when products are added, or set reasonable expiration
wp_cache_set( 'all_products', $results, 'products', 3600 ); // 1 hour
Testing Cache Effectiveness
Testing whether your caching is actually effective is crucial. You need to verify:
- Cache is actually being hit (not just misses)
- Cached data is correct
- Cache invalidation works properly
// Simple cache hit/miss tracking
class Cache_Tracker {
private static $hits = 0;
private static $misses = 0;
public static function get_with_tracking( $key, $group = '' ) {
$value = wp_cache_get( $key, $group );
if ( false === $value ) {
self::$misses++;
} else {
self::$hits++;
}
return $value;
}
public static function get_stats() {
$total = self::$hits + self::$misses;
$hit_rate = $total > 0 ? ( self::$hits / $total ) * 100 : 0;
return array(
'hits' => self::$hits,
'misses' => self::$misses,
'total' => $total,
'hit_rate' => round( $hit_rate, 2 ) . '%',
);
}
}
// Log cache stats
add_action( 'wp_footer', function() {
if ( current_user_can( 'manage_options' ) && WP_DEBUG ) {
$stats = Cache_Tracker::get_stats();
error_log( 'Cache stats: ' . json_encode( $stats ) );
}
} );
Add a debug bar to see cache performance:
// Add debug info to WordPress admin bar
add_action( 'admin_bar_menu', function( $wp_admin_bar ) {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$stats = Cache_Tracker::get_stats();
$wp_admin_bar->add_menu( array(
'id' => 'cache-stats',
'title' => 'Cache: ' . $stats['hit_rate'],
'href' => '#',
) );
}, 100 );
Test cache invalidation with unit tests:
// Test cache invalidation
class Test_Product_Cache extends WP_UnitTestCase {
public function test_cache_hit() {
$product_id = $this->factory->post->create( array( 'post_type' => 'product' ) );
// First call - cache miss
$result1 = Product_Cache::get_by_id( $product_id );
// Second call - cache hit
$result2 = Product_Cache::get_by_id( $product_id );
$this->assertEquals( $result1, $result2 );
}
public function test_cache_invalidation() {
$product_id = $this->factory->post->create( array( 'post_type' => 'product' ) );
// Cache the product
$result1 = Product_Cache::get_by_id( $product_id );
// Invalidate cache
Product_Cache::invalidate( $product_id );
// Update the product
wp_update_post( array(
'ID' => $product_id,
'post_title' => 'New Title',
) );
// Next retrieval should fetch fresh data
$result2 = Product_Cache::get_by_id( $product_id );
$this->assertNotEquals( $result1['title'], $result2['title'] );
}
}
Use WP HealthKit to scan your plugin for caching issues and recommendations.
Additional Resources
For a comprehensive view of how WP HealthKit approaches plugin analysis, explore our 17 verification layers or browse the plugin directory to see real audit scores. Ready to check your own plugin? Run a free audit now.
Broader Context and Best Practices
Performance optimization in WordPress plugins requires understanding the full request lifecycle, from the initial HTTP request through PHP execution, database queries, and response generation. Every millisecond added to this cycle multiplies across every page load for every visitor. A plugin that adds just 50 milliseconds of overhead might seem insignificant, but on a site serving 100,000 page views per day, that translates to nearly 1,400 hours of cumulative user waiting time per year. This perspective helps prioritize optimization efforts where they have the greatest impact on real user experience.
Database queries are the most common performance bottleneck in WordPress plugins, but not all query optimization strategies are equally effective. Adding an index speeds up read operations but slows down writes. Caching eliminates queries entirely but introduces cache invalidation complexity. Denormalization reduces JOIN operations but creates data consistency challenges. Understanding these trade-offs is essential for making informed optimization decisions rather than blindly applying generic advice. Profiling tools and query monitoring help identify which specific queries deserve optimization attention and which optimization strategy best fits each situation.
Core Web Vitals have fundamentally changed how performance is measured and valued. Google's inclusion of LCP, FID, and CLS as ranking factors means that plugin performance now directly impacts site owners' search visibility and revenue. Plugin developers who ignore performance are not just creating a poor user experience. They are actively harming their users' business outcomes. This responsibility drives the growing demand for performance-conscious plugin development and automated performance testing as part of the plugin development workflow.
The relationship between performance and security is often overlooked but critically important. Performance bottlenecks can become denial-of-service vectors when attackers identify expensive operations they can trigger repeatedly. A poorly optimized database query that takes two seconds under normal load might be weaponized to consume all available database connections. Similarly, memory-intensive operations without proper limits can be exploited to crash PHP worker processes. Performance optimization and security hardening are complementary disciplines that reinforce each other when approached holistically.
Frequently Asked Questions
Should I cache every database query?
Not necessarily. Cache expensive operations: complex queries, external API calls, heavy computations. Don't cache simple, fast queries. Profile your code to find the bottlenecks, then cache those.
How do I know if caching is actually helping?
Measure. Use performance profiling tools and cache tracking to see cache hit rates. If hit rate is above 80%, caching is likely helping. Below 50%, reconsider your caching strategy.
What's the difference between transients and object cache?
Transients are a higher-level API that abstracts the cache backend. Object cache is lower-level and gives you direct control. For new code, prefer object cache—it's simpler and more predictable.
Can I cache user-specific data?
Yes, but include the user ID in the cache key: $key = 'user_data_' . $user_id. This prevents one user's data from appearing to another user.
What happens if my Redis goes down?
WordPress gracefully falls back to non-persistent cache. Requests might be slower, but your site continues working. You should monitor Redis health and restart it if it crashes.
How much cache do I actually need?
Start with 256MB of Redis/Memcached and increase if you hit memory limits. Monitor cache hit rates—if they're low, you might need more memory, or your cache keys are too granular.
Conclusion
WordPress object caching is powerful when implemented correctly. The API is simple—wp_cache_get(), wp_cache_set(), and friends—but effective caching requires thinking carefully about cache groups, expiration times, and invalidation strategies.
Master these patterns:
- Check for
false ===when testing cache hits - Use cache groups for organization
- Invalidate cache when data changes
- Set reasonable expiration times for persistent cache
- Measure cache effectiveness with hit rate tracking
For complex caching patterns, use hierarchical cache groups and tag-based invalidation. For persistent caching, configure Redis and set appropriate expiration times to prevent memory issues.
Implement WP HealthKit to automatically scan your plugins for caching issues and optimization opportunities. Regular scanning helps you identify where caching could have the most impact.
Remember: premature optimization is the root of all evil, but thoughtful caching of known bottlenecks is smart engineering. Profile your code, identify expensive operations, cache them strategically, and measure the results. That's how you build fast, efficient WordPress plugins.