Skip to main content
WP HealthKit

WordPress REST API Client Caching: HTTP Cache Headers

September 16, 202617 min readPerformanceBy Jamie

Table of Contents

  1. Understanding REST API Caching Fundamentals
  2. ETag Headers and Resource Validation
  3. Last-Modified Headers and Conditional Requests
  4. Cache-Control Directives for Client Caching
  5. Stale-While-Revalidate for Background Updates
  6. Implementing Headers in REST Endpoints
  7. Testing and Validation Strategies

WordPress REST API client-side caching strategy fundamentally changes how your application consumes API data. Rather than refetching the same endpoint repeatedly, HTTP cache headers enable browsers and HTTP clients to intelligently cache responses and validate freshness, reducing bandwidth and latency significantly.

The REST API in WordPress offers a powerful interface for decoupled frontends, mobile applications, and JavaScript frameworks. However, without proper caching strategies, API consumption can generate massive database load as clients repeatedly request the same data. By implementing proper HTTP cache headers, you enable clients to cache API responses intelligently, dramatically reducing both bandwidth and server load.

Understanding REST API Caching Fundamentals

HTTP caching operates at the client level and is fundamentally different from server-side caching. When you set proper HTTP headers, browsers, proxies, and API clients automatically cache responses and validate them before making new requests. This reduces round trips to the server and decreases latency for clients.

The HTTP caching protocol is defined by RFC 7234 and includes mechanisms for public caches (shared between users), private caches (individual user browsers), expiration times, and validation mechanisms. Understanding this protocol enables you to build APIs that perform excellently even under high concurrency.

Three primary HTTP headers govern caching behavior:

  1. Cache-Control - Specifies how long content can be cached and under what conditions
  2. ETag - A token representing content version, used for validation
  3. Last-Modified - A timestamp indicating when content was last changed

These headers work together to enable different caching strategies. Some endpoints might be cached for long periods with simple expiration. Other endpoints might use validation headers to check freshness without downloading full responses.

// Example: Basic HTTP caching headers
function wp_rest_api_cache_headers( $response ) {
    $response->header( 'Cache-Control', 'public, max-age=3600' );
    $response->header( 'ETag', md5( maybe_serialize( $response->get_data() ) ) );
    $response->header( 'Last-Modified', gmdate( 'D, d M Y H:i:s T', current_time( 'timestamp' ) ) );
    
    return $response;
}

add_filter( 'rest_post_dispatch', 'wp_rest_api_cache_headers' );

Understanding these mechanisms is essential for building performant REST APIs. WP HealthKit's REST API auditing analyzes whether your endpoints properly implement caching headers and identifies opportunities for optimization.

ETag Headers and Resource Validation

An ETag (entity tag) is a unique identifier representing the specific version of a resource. When content changes, the ETag changes. Clients can send conditional requests including an If-None-Match header with the previous ETag. The server compares the provided ETag with the current version. If they match, the server responds with 304 Not Modified without sending the full response body.

This mechanism is incredibly efficient. Instead of sending a 10KB response on every request, the server sends a tiny 304 response only when content has changed. The client uses the cached version and saves bandwidth on requests where content hasn't been modified.

Generating appropriate ETags is crucial. The ETag should change whenever the content changes but remain consistent as long as the content is identical. For posts, you might use the post's last modified time. For complex objects, you might hash the serialized data.

// Example: Generating ETags for REST endpoints
function generate_post_etag( $post_id ) {
    $post = get_post( $post_id );
    
    if ( ! $post ) {
        return null;
    }
    
    // Include post data and metadata in ETag
    $data = array(
        'content' => $post->post_content,
        'title' => $post->post_title,
        'modified' => $post->post_modified,
        'meta' => get_post_meta( $post_id ),
    );
    
    return md5( maybe_serialize( $data ) );
}

function rest_post_collection_params_callback( $response, $post_type, $post ) {
    $etag = generate_post_etag( $post->ID );
    
    $response->header( 'ETag', '"' . $etag . '"' );
    
    return $response;
}

// Handle If-None-Match conditional requests
function handle_etag_conditional_request() {
    if ( ! isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) {
        return false;
    }
    
    $request_etag = sanitize_text_field( $_SERVER['HTTP_IF_NONE_MATCH'] );
    $current_etag = generate_post_etag( get_the_ID() );
    
    if ( trim( $request_etag, '"' ) === $current_etag ) {
        // Content hasn't changed
        return true;
    }
    
    return false;
}

One challenge with ETags is generation performance. Computing hashes of large response bodies on every request defeats the purpose of caching. Better approaches include using the WordPress revision ID, post modification timestamp, or a combination of metadata timestamps.

// Example: Efficient ETag generation using WordPress data
function fast_post_etag( $post_id ) {
    $post = get_post( $post_id );
    
    if ( ! $post ) {
        return null;
    }
    
    // Use post modified time as basis for ETag
    // This automatically changes when post updates
    return md5( $post_id . '-' . $post->post_modified_gmt );
}

Last-Modified Headers and Conditional Requests

The Last-Modified header indicates when a resource was last changed. Clients store this header and send subsequent requests with an If-Modified-Since header containing the previous Last-Modified value. The server responds with 304 Not Modified if the resource hasn't changed since that timestamp.

This approach is complementary to ETags but based on time rather than content hashing. It's particularly useful for resources that have obvious modification times, like posts or comments.

// Example: Implementing Last-Modified headers
function get_post_last_modified_header( $post_id ) {
    $post = get_post( $post_id );
    
    if ( ! $post ) {
        return null;
    }
    
    // Use post's modified time
    $timestamp = strtotime( $post->post_modified_gmt );
    
    return gmdate( 'D, d M Y H:i:s T', $timestamp );
}

function rest_prepare_post_with_cache_headers( $response, $post, $request ) {
    $last_modified = get_post_last_modified_header( $post->ID );
    
    if ( $last_modified ) {
        $response->header( 'Last-Modified', $last_modified );
    }
    
    return $response;
}

add_filter( 'rest_prepare_post', 'rest_prepare_post_with_cache_headers', 10, 3 );

// Handle conditional requests
function handle_if_modified_since_request() {
    if ( ! isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) {
        return false;
    }
    
    $if_modified_since = sanitize_text_field( $_SERVER['HTTP_IF_MODIFIED_SINCE'] );
    $last_modified = get_post_last_modified_header( get_the_ID() );
    
    if ( strtotime( $if_modified_since ) >= strtotime( $last_modified ) ) {
        // Content hasn't modified since provided timestamp
        return true;
    }
    
    return false;
}

For complex resources with multiple components, determining the appropriate Last-Modified time requires careful consideration. If a post includes comments, the post's modified time might not reflect when the comments changed. You might need a more sophisticated approach that considers all relevant data.

// Example: Last-Modified time considering multiple data sources
function get_post_collection_last_modified( $post_id ) {
    $post = get_post( $post_id );
    
    $times = array(
        strtotime( $post->post_modified_gmt ),
        strtotime( $post->post_date_gmt ), // Fallback to publication time
    );
    
    // Check if featured image changed
    $featured_image_id = get_post_thumbnail_id( $post_id );
    if ( $featured_image_id ) {
        $featured_image = get_post( $featured_image_id );
        $times[] = strtotime( $featured_image->post_modified_gmt );
    }
    
    // Check if post meta changed
    $meta_objects = get_post_meta( $post_id );
    foreach ( $meta_objects as $meta ) {
        if ( is_array( $meta ) ) {
            foreach ( $meta as $item ) {
                if ( isset( $item->meta_id ) ) {
                    // Use meta ID as proxy for modification time
                    $times[] = $item->meta_id;
                }
            }
        }
    }
    
    $latest_time = max( $times );
    
    return gmdate( 'D, d M Y H:i:s T', $latest_time );
}

Cache-Control Directives for Client Caching

The Cache-Control header is the most flexible HTTP caching mechanism. It accepts multiple directives that control how clients and intermediate proxies handle cached responses.

Key directives include:

  • public - Any cache can store this response (default)
  • private - Only the client browser can cache this (not shared proxies)
  • max-age - How many seconds the response is fresh
  • no-cache - Revalidate with server before using cached version
  • no-store - Never cache this response
  • must-revalidate - Revalidate if cached version expires
  • stale-while-revalidate - Serve stale response while revalidating in background
// Example: Cache-Control directives for different endpoint types
function apply_cache_control_headers( $response, $post_type, $post ) {
    // Caching strategy depends on post type and user permissions
    
    if ( current_user_can( 'read' ) ) {
        // Authenticated users get private caches (browser cache only)
        // Shorter max-age since user-specific data might be sensitive
        $response->header( 'Cache-Control', 'private, max-age=300' );
    } else {
        // Anonymous users get public caches (shareable via proxy)
        // Longer max-age since content is not user-specific
        $response->header( 'Cache-Control', 'public, max-age=3600' );
    }
    
    // Immutable content can be cached indefinitely
    if ( $post->post_type === 'attachment' && strtotime( $post->post_date ) < strtotime( '-1 month' ) ) {
        $response->header( 'Cache-Control', 'public, max-age=31536000, immutable' );
    }
    
    return $response;
}

// Different caching for different routes
function apply_endpoint_cache_control( $response, $route ) {
    if ( strpos( $route, '/wp/v2/posts' ) === 0 ) {
        // Post lists can be cached longer
        $response->header( 'Cache-Control', 'public, max-age=600' );
    } elseif ( strpos( $route, '/wp/v2/comments' ) === 0 ) {
        // Comments change frequently, shorter cache
        $response->header( 'Cache-Control', 'public, max-age=60' );
    } elseif ( strpos( $route, '/wp/v2/categories' ) === 0 ) {
        // Categories rarely change, cache longer
        $response->header( 'Cache-Control', 'public, max-age=86400' );
    }
    
    return $response;
}

Understanding these directives enables fine-grained control over caching behavior. Different data has different freshness requirements. By matching Cache-Control values to your content's change frequency, you optimize for both client performance and data freshness.

Stale-While-Revalidate for Background Updates

The stale-while-revalidate directive enables a sophisticated caching pattern where clients continue serving cached content briefly after it expires, while revalidating with the server in the background. This provides immediate response times while ensuring data is updated asynchronously.

This directive is particularly valuable for data that doesn't require absolute freshness. Post listings, category information, and similar content can use this pattern effectively. Users get instant responses from cache while the system quietly refreshes the data.

// Example: Using stale-while-revalidate
function apply_stale_while_revalidate_header( $response ) {
    // Cache for 1 hour, but serve stale content for up to 1 day
    // while revalidating in background
    $response->header( 'Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400' );
    
    return $response;
}

// This enables browsers to serve 1-hour-old content for up to 1 day
// while revalidating in the background

This pattern is ideal for WordPress REST API endpoints that serve mostly static content. Even if a post is updated, users with cached versions see the old content briefly while their browser fetches the update. For most use cases, this delayed consistency is acceptable and the performance benefit is significant.

Implementing Headers in REST Endpoints

Implementing caching headers requires adding them to REST endpoint responses. WordPress REST API provides hooks for modifying responses globally or per-endpoint.

// Example: Comprehensive REST API caching implementation
class RestApiCacheManager {
    public function register_hooks() {
        add_filter( 'rest_post_dispatch', array( $this, 'add_cache_headers' ) );
        add_filter( 'rest_prepare_post', array( $this, 'add_post_cache_headers' ), 10, 3 );
        add_filter( 'rest_prepare_post_type_object', array( $this, 'add_static_cache_headers' ) );
    }
    
    public function add_cache_headers( $response ) {
        // Apply global caching headers
        $response->header( 'Vary', 'Accept-Encoding, Accept' );
        
        return $response;
    }
    
    public function add_post_cache_headers( $response, $post, $request ) {
        $post_type = get_post_type( $post );
        
        // Determine cache duration based on post type
        $cache_duration = $this->get_cache_duration_for_type( $post_type );
        
        // Add primary caching headers
        $response->header( 'Cache-Control', "public, max-age={$cache_duration}, stale-while-revalidate=604800" );
        
        // Add ETag
        $etag = $this->generate_post_etag( $post->ID );
        $response->header( 'ETag', '"' . $etag . '"' );
        
        // Add Last-Modified
        $response->header( 'Last-Modified', gmdate( 'D, d M Y H:i:s T', strtotime( $post->post_modified_gmt ) ) );
        
        // Add Vary header to indicate what factors affect caching
        $response->header( 'Vary', 'Accept, Accept-Encoding' );
        
        return $response;
    }
    
    public function add_static_cache_headers( $response ) {
        // Post types and taxonomies change rarely, cache longer
        $response->header( 'Cache-Control', 'public, max-age=604800, immutable' );
        
        return $response;
    }
    
    private function get_cache_duration_for_type( $post_type ) {
        $durations = array(
            'post' => 3600,      // 1 hour
            'page' => 7200,      // 2 hours
            'product' => 1800,   // 30 minutes (changes frequently)
            'event' => 300,      // 5 minutes (very dynamic)
        );
        
        return $durations[ $post_type ] ?? 3600;
    }
    
    private function generate_post_etag( $post_id ) {
        $post = get_post( $post_id );
        
        $etag_source = array(
            'id' => $post->ID,
            'modified' => $post->post_modified_gmt,
            'meta_version' => wp_hash( maybe_serialize( get_post_meta( $post_id ) ) ),
        );
        
        return md5( maybe_serialize( $etag_source ) );
    }
}

// Initialize
$cache_manager = new RestApiCacheManager();
$cache_manager->register_hooks();

Testing and Validation Strategies

Proper implementation requires testing that caching headers are applied correctly and conditional requests work as expected. You need to verify that clients receive 304 responses when appropriate and that ETags and Last-Modified headers are consistent.

// Example: Testing cache headers
class RestApiCachingTest extends WP_UnitTestCase {
    public function test_post_response_includes_cache_headers() {
        $post_id = self::factory()->post->create();
        $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . $post_id );
        
        $controller = new WP_REST_Posts_Controller( 'post' );
        $response = $controller->get_item( $request );
        
        // Verify Cache-Control header exists
        $this->assertNotNull( $response->get_headers()['Cache-Control'] );
        $this->assertStringContainsString( 'max-age', $response->get_headers()['Cache-Control'] );
        
        // Verify ETag header exists
        $this->assertNotNull( $response->get_headers()['ETag'] );
        
        // Verify Last-Modified header exists
        $this->assertNotNull( $response->get_headers()['Last-Modified'] );
    }
    
    public function test_conditional_request_returns_304() {
        $post_id = self::factory()->post->create();
        
        // First request
        $request1 = new WP_REST_Request( 'GET', '/wp/v2/posts/' . $post_id );
        $controller = new WP_REST_Posts_Controller( 'post' );
        $response1 = $controller->get_item( $request1 );
        
        $etag = $response1->get_headers()['ETag'];
        
        // Conditional request with ETag
        $request2 = new WP_REST_Request( 'GET', '/wp/v2/posts/' . $post_id );
        $request2->set_header( 'If-None-Match', $etag );
        
        $response2 = $controller->get_item( $request2 );
        
        // Should return 304 Not Modified
        $this->assertEquals( 304, $response2->get_status() );
    }
}

WP HealthKit's REST API auditing can verify that your endpoints properly implement caching headers and test conditional request functionality. This ensures your API takes advantage of HTTP caching for optimal performance.

FAQ

Q: Should I use ETags or Last-Modified headers? A: Both are valuable. Last-Modified is simpler and covers most cases. ETags are more precise when content depends on multiple sources or when timestamps aren't reliable. Use both together for maximum compatibility.

Q: How long should I cache REST API responses? A: It depends on content volatility. Posts might cache for 1 hour, static content for 1 day. Use stale-while-revalidate to serve slightly-aged content while updating in background. Monitor your data freshness requirements and adjust accordingly.

Q: Will caching break real-time features? A: Caching with stale-while-revalidate balances freshness and performance. For truly real-time data, use shorter max-age values or WebSocket-based updates rather than polling REST endpoints.

Q: How do cache headers affect user-specific data? A: User-specific content should use Cache-Control: private, preventing proxy caching. Even with private caches, use shorter max-age values since the data is sensitive.

Q: Can WP HealthKit audit my REST API caching strategy? A: Yes, WP HealthKit analyzes REST endpoints and verifies proper cache header implementation. It identifies missing headers, incorrect values, and optimization opportunities.

Q: What about caching authenticated requests? A: Use Cache-Control: private for authenticated content. Vary header should include authentication state. Consider user roles and capabilities when determining cache duration.

Additional Resources

For a comprehensive view of how WP HealthKit approaches plugin analysis, explore our 62 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.

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. Understanding these trade-offs is essential for making informed optimization decisions.

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.

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.

Broader Industry Context and Best Practices

WordPress performance optimization requires understanding the full request lifecycle, from DNS resolution through server-side processing to client-side rendering. Each stage presents optimization opportunities: DNS prefetching reduces lookup latency, server-side caching eliminates redundant database queries, and client-side optimization reduces time to interactive. WP HealthKit identifies performance bottlenecks across the entire stack, providing actionable recommendations that prioritize improvements by expected impact. Performance monitoring should establish baselines and track trends over time, enabling teams to detect gradual degradation before it becomes noticeable to users or affects search engine rankings.

Frequently Asked Questions

How does WP HealthKit identify performance issues in plugins?

WP HealthKit analyzes database query patterns, memory usage, asset loading strategies, and caching implementation to identify performance bottlenecks. The tool flags unoptimized queries, excessive autoloaded options, missing indexes, and inefficient hook usage that impact page load times.

What are the most common WordPress plugin performance problems?

The most frequent performance issues include unindexed database queries on large tables, excessive autoloaded options consuming memory on every page load, redundant HTTP requests to external APIs, unoptimized asset loading without conditional enqueuing, and missing object caching for expensive computations.

How do Core Web Vitals relate to WordPress plugin performance?

Plugins directly impact Core Web Vitals through their effect on server response time affecting Largest Contentful Paint, JavaScript execution blocking First Input Delay, and layout-shifting content affecting Cumulative Layout Shift. Poor plugin performance can significantly harm a site's search rankings.

Should I use Redis or Memcached for WordPress object caching?

Redis offers persistence, data structures, and replication features that make it more versatile for WordPress. Memcached provides simpler multi-threaded performance for pure key-value caching. For most WordPress installations, Redis is the better choice due to its broader feature set and active community support.

How can I measure the performance impact of my WordPress plugin?

Use Query Monitor to track database queries and hooks, profile with Xdebug or Blackfire to identify bottlenecks, run load tests with k6 or Apache Bench to measure throughput, and monitor Core Web Vitals with Lighthouse CI to ensure your plugin meets performance standards.

Conclusion

WordPress REST API client-side caching strategy represents a powerful optimization opportunity often overlooked in plugin development. By properly implementing ETag, Last-Modified, and Cache-Control headers, you enable browsers and API clients to intelligently cache responses, reducing both bandwidth consumption and server load.

The most effective approach combines Cache-Control directives for predictable expiration with ETag or Last-Modified headers for validation. Stale-while-revalidate provides an excellent balance between performance and freshness, serving cached content while updating asynchronously.

Implementing these headers requires understanding HTTP caching protocol and careful consideration of your content's volatility. Different data types deserve different caching strategies. Posts might use different settings than comments or custom post types.

WP HealthKit helps ensure your REST API leverages proper caching strategies by analyzing endpoint implementations and verifying cache header correctness. Our auditing identifies opportunities to improve API performance through better caching.

Optimize your WordPress REST API with WP HealthKit. Upload your plugins to analyze REST endpoint caching implementation, verify proper header usage, and discover performance optimization opportunities. Get detailed recommendations for implementing effective caching strategies. Start your comprehensive API audit today.

Ready to audit your plugin?

WP HealthKit checks for all the issues in this article and 40+ more across 62 verification layers.

Comments