WordPress Cache-Control Plugin: Implementation Notes
WordPress's default HTTP header behaviour for HTML pages is functionally broken from a caching perspective: it sends Cache-Control: no-cache, must-revalidate, max-age=0 for almost everything, which forces every page load to hit PHP even when the content hasn't changed in months. The standard advice — install a caching plugin — solves this at the full-page cache level but doesn't give fine-grained control over per-content-type cache lifetimes, CDN versus browser cache differentiation, or stale-while-revalidate policies. A small custom plugin using WordPress's native hook system can set correct Cache-Control headers based on content type without the overhead of a full-page cache system. This page covers the relevant hook points, the WP_Query conditional functions that identify content type, CDN-specific header patterns, and the cache invalidation hook when posts update. Part of the developer notes section. For server-level HTTP optimisation context, Apache mod_brotli covers complementary territory. The web performance topic hub provides broader context.
Why WordPress's default headers are a problem
WordPress sends Cache-Control: no-cache, must-revalidate, max-age=0 as its default for HTML responses. This instruction tells both browsers and CDNs to treat the response as uncacheable, requiring a round-trip to the origin on every request. For a static post that was published two years ago and hasn't been edited since, this means every visitor generates a PHP execution round-trip. The correct header for an old, stable post is something like Cache-Control: public, max-age=600, s-maxage=86400, stale-while-revalidate=3600 — which lets a CDN serve it for a day and allows browsers to serve from cache for 10 minutes.
The default exists because WordPress can't know whether any given page is cacheable without context — logged-in users should not see cached responses from other users, comment forms need CSRF tokens, and WooCommerce cart pages must never be cached. The plugin approach adds the context that WordPress's default handler lacks.
Hook points
Two hooks control response headers in WordPress:
send_headers: Fires during request processing, after the query is set up but before output begins. The WP_Query global is populated and all conditional functions (is_single(), is_page(), etc.) work correctly here. This is the primary hook for Cache-Control logic.
wp_headers: A filter that takes the headers array and returns it modified. Useful when you want to modify rather than replace the full header set.
<?php
/**
* Plugin Name: Cache-Control Headers
* Description: Sets appropriate Cache-Control headers by content type.
*/
add_action('send_headers', 'cc_set_cache_headers');
function cc_set_cache_headers(): void {
// Don't cache anything for logged-in users:
if (is_user_logged_in()) {
header('Cache-Control: private, no-store');
return;
}
// Don't cache search results, 404s, or anything dynamic:
if (is_search() || is_404() || is_feed()) {
header('Cache-Control: no-store');
return;
}
cc_apply_public_cache_headers();
}
Content type logic
The cc_apply_public_cache_headers() function maps WordPress query context to cache lifetimes:
function cc_apply_public_cache_headers(): void {
// Archive/listing pages: moderate browser cache, longer CDN cache
if (is_home() || is_archive() || is_category() || is_tag()) {
header('Cache-Control: public, max-age=300, s-maxage=3600, stale-while-revalidate=600');
return;
}
// Individual posts: longer browser cache, long CDN cache
if (is_single()) {
// Adjust max-age based on post age (older = longer cache)
$post_age = time() - get_post_time('U');
$max_age = ($post_age > WEEK_IN_SECONDS) ? 600 : 120;
header("Cache-Control: public, max-age={$max_age}, s-maxage=86400, stale-while-revalidate=3600");
return;
}
// Static pages: long cache
if (is_page()) {
header('Cache-Control: public, max-age=600, s-maxage=86400, stale-while-revalidate=7200');
return;
}
// Front page: shorter cache (frequently changing)
if (is_front_page()) {
header('Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=120');
return;
}
// Default: conservative public cache
header('Cache-Control: public, max-age=60, s-maxage=600');
}
The post-age-based cache differentiation — shorter browser cache for recent posts, longer for older ones — reflects the reality that recently published posts are more likely to receive editorial corrections within the first few hours. A post published yesterday might get a factual update. A post published three years ago almost certainly won't change today. Giving recent posts a 2-minute browser cache and older posts a 10-minute browser cache costs nothing meaningful in CDN terms but provides a reasonable window for authors to catch and fix errors without readers seeing stale content.
CDN cache invalidation on post update
add_action('save_post', 'cc_purge_on_post_save', 10, 2);
function cc_purge_on_post_save(int $post_id, WP_Post $post): void {
// Skip autosaves and revisions:
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
return;
}
// Skip non-published posts:
if ($post->post_status !== 'publish') {
return;
}
$url = get_permalink($post_id);
// Trigger CDN purge via API — implementation varies by CDN provider:
cc_purge_cdn_url($url);
// Also purge archive/index pages that list this post:
cc_purge_cdn_url(home_url('/'));
if ($categories = get_the_category($post_id)) {
foreach ($categories as $cat) {
cc_purge_cdn_url(get_category_link($cat->term_id));
}
}
}
The CDN purge implementation (cc_purge_cdn_url) is provider-specific — Cloudflare uses the Cache Purge API with a zone ID and auth token; Fastly uses surrogate keys; Varnish uses PURGE HTTP method requests to the cache node. The WordPress hook structure is provider-agnostic.
Pitfalls
The most common mistake is setting a long max-age without also setting s-maxage, then expecting the CDN to respect max-age for edge caching. The max-age directive controls browser cache; CDNs typically honour s-maxage for their edge cache lifetime if it's present, or fall back to max-age if not. Setting both lets you differentiate: a 1-minute browser cache and a 1-day CDN cache is a perfectly sensible combination for most post types.
WordPress's nocache_headers() function — called internally for admin pages, login screens, and WooCommerce cart pages — adds aggressive no-cache headers including Cache-Control: no-cache, must-revalidate, max-age=0 and also sets Pragma: no-cache. If any plugin or theme calls nocache_headers() on a page that should be cached, the custom plugin's headers will be overridden. Hook priority matters: use a late priority (e.g., 99) on send_headers to fire after other plugins' header manipulation.
The stale-while-revalidate directive was not widely supported by CDNs until around 2020. Modern CDN edge networks — Cloudflare, Fastly, CloudFront — now support it, making it practical to include in Cache-Control headers. It tells the CDN (and supporting browsers) to serve cached content immediately even after it expires, while asynchronously fetching a fresh copy. For a modestly-updated blog, this eliminates the cold-cache hit latency for real users while still ensuring the CDN eventually pulls fresh content.