1"use strict";(globalThis.webpackChunksite=globalThis.webpackChunksite||[]).push([[7123],{6365(e,n,t){t.r(n),t.d(n,{assets:()=>f,contentTitle:()=>m,default:()=>_,frontMatter:()=>g,metadata:()=>s,toc:()=>x});const s=JSON.parse('{"type":"mdx","permalink":"/devel/wordpress-cache-control-plugin/","source":"@site/src/pages/devel/wordpress-cache-control-plugin/index.mdx","title":"WordPress Cache-Control Plugin: Implementation Notes","description":"Implementing fine-grained Cache-Control headers in WordPress via plugin \u2014 hook points, post type logic, CDN vs browser cache differentiation, and stale-while-revalidate patterns.","frontMatter":{"title":"WordPress Cache-Control Plugin: Implementation Notes","description":"Implementing fine-grained Cache-Control headers in WordPress via plugin \u2014 hook points, post type logic, CDN vs browser cache differentiation, and stale-while-revalidate patterns.","last_update":{"date":"2025-09-01T00:00:00.000Z"}},"unlisted":false}');var r=t(4848),a=t(8453),o=t(5260),i=t(2299),c=t(2172),l=t(5206),d=t(5894),h=t(1583),p=t(9826),u=(t(7533),t(5914));const g={title:"WordPress Cache-Control Plugin: Implementation Notes",description:"Implementing fine-grained Cache-Control headers in WordPress via plugin \u2014 hook points, post type logic, CDN vs browser cache differentiation, and stale-while-revalidate patterns.",last_update:{date:new Date("2025-09-01T00:00:00.000Z")}},m="WordPress Cache-Control Plugin: Implementation Notes",f={},x=[{value:"Why WordPress's default headers are a problem",id:"why-wordpresss-default-headers-are-a-problem",level:2},{value:"Hook points",id:"hook-points",level:2},{value:"Content type logic",id:"content-type-logic",level:2},{value:"CDN cache invalidation on post update",id:"cdn-cache-invalidation-on-post-update",level:2},{value:"Pitfalls",id:"pitfalls",level:2}];function v(e){const n={a:"a",code:"code",h1:"h1",h2:"h2",header:"header",hr:"hr",p:"p",pre:"pre",strong:"strong",...(0,a.R)(),...e.components};return(0,r.jsxs)(r.Fragment,{children:[(0,r.jsxs)(o.A,{children:[(0,r.jsx)("link",{rel:"canonical",href:"https://slightfuture.com/devel/wordpress-cache-control-plugin/"}),(0,r.jsx)("meta",{property:"og:title",content:"WordPress Cache-Control Plugin: Implementation Notes \u2014 Slight Future"}),(0,r.jsx)("meta",{property:"og:description",content:"Implementing fine-grained Cache-Control headers in WordPress via plugin \u2014 hook points, post type logic, CDN vs browser cache differentiation, and stale-while-revalidate patterns."}),(0,r.jsx)("meta",{property:"og:image",content:"https://slightfuture.com/img/pages/wordpress-cache-control-plugin-1600x900.jpg"}),(0,r.jsx)("meta",{property:"og:url",content:"https://slightfuture.com/devel/wordpress-cache-control-plugin/"})]}),"\n",(0,r.jsx)("img",{src:"/img/pages/wordpress-cache-control-plugin-1600x900.jpg",alt:"WordPress Cache-Control plugin architecture showing hook points, WP_Query context, and CDN cache header logic",width:1600,height:900,style:{width:"100%",height:"auto",borderRadius:"var(--sf-radius-lg)",marginBottom:"1.5rem"}}),"\n",(0,r.jsx)(n.header,{children:(0,r.jsx)(n.h1,{id:"wordpress-cache-control-plugin-implementation-notes",children:"WordPress Cache-Control Plugin: Implementation Notes"})}),"\n",(0,r.jsxs)(n.p,{children:["WordPress's default HTTP header behaviour for HTML pages is functionally broken from a caching perspective: it sends ",(0,r.jsx)(n.code,{children:"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 \u2014 install a caching plugin \u2014 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 ",(0,r.jsx)(n.a,{href:"/devel/",children:"developer notes"})," section. For server-level HTTP optimisation context, ",(0,r.jsx)(n.a,{href:"/webdev/httpd-mod-brotli/",children:"Apache mod_brotli"})," covers complementary territory. The ",(0,r.jsx)(n.a,{href:"/topic/web-performance/",children:"web performance"})," topic hub provides broader context."]}),"\n",(0,r.jsx)(n.hr,{}),"\n",(0,r.jsx)(n.h2,{id:"why-wordpresss-default-headers-are-a-problem",children:"Why WordPress's default headers are a problem"}),"\n",(0,r.jsx)(p.A,{children:(0,r.jsxs)(n.p,{children:["WordPress sends ",(0,r.jsx)("code",{children:"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 ",(0,r.jsx)("code",{children:"Cache-Control: public, max-age=600, s-maxage=86400, stale-while-revalidate=3600"})," \u2014 which lets a CDN serve it for a day and allows browsers to serve from cache for 10 minutes."]})}),"\n",(0,r.jsx)(n.p,{children:"The default exists because WordPress can't know
1whether any given page is cacheable without context \u2014 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."}),"\n",(0,r.jsx)(n.hr,{}),"\n",(0,r.jsx)(n.h2,{id:"hook-points",children:"Hook points"}),"\n",(0,r.jsx)(n.p,{children:"Two hooks control response headers in WordPress:"}),"\n",(0,r.jsxs)(n.p,{children:[(0,r.jsxs)(n.strong,{children:[(0,r.jsx)(n.code,{children:"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 (",(0,r.jsx)(n.code,{children:"is_single()"}),", ",(0,r.jsx)(n.code,{children:"is_page()"}),", etc.) work correctly here. This is the primary hook for Cache-Control logic."]}),"\n",(0,r.jsxs)(n.p,{children:[(0,r.jsxs)(n.strong,{children:[(0,r.jsx)(n.code,{children:"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."]}),"\n",(0,r.jsx)(d.A,{label:"Plugin hook structure for Cache-Control",children:(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-php",children:"<?php\n/**\n * Plugin Name: Cache-Control Headers\n * Description: Sets appropriate Cache-Control headers by content type.\n */\n\nadd_action('send_headers', 'cc_set_cache_headers');\n\nfunction cc_set_cache_headers(): void {\n // Don't cache anything for logged-in users:\n if (is_user_logged_in()) {\n header('Cache-Control: private, no-store');\n return;\n }\n\n // Don't cache search results, 404s, or anything dynamic:\n if (is_search() || is_404() || is_feed()) {\n header('Cache-Control: no-store');\n return;\n }\n\n cc_apply_public_cache_headers();\n}\n"})})}),"\n",(0,r.jsx)(n.hr,{}),"\n",(0,r.jsx)(n.h2,{id:"content-type-logic",children:"Content type logic"}),"\n",(0,r.jsxs)(n.p,{children:["The ",(0,r.jsx)(n.code,{children:"cc_apply_public_cache_headers()"})," function maps WordPress query context to cache lifetimes:"]}),"\n",(0,r.jsx)(d.A,{label:"Content-type-aware Cache-Control logic",children:(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-php",children:"function cc_apply_public_cache_headers(): void {\n // Archive/listing pages: moderate browser cache, longer CDN cache\n if (is_home() || is_archive() || is_category() || is_tag()) {\n header('Cache-Control: public, max-age=300, s-maxage=3600, stale-while-revalidate=600');\n return;\n }\n\n // Individual posts: longer browser cache, long CDN cache\n if (is_single()) {\n // Adjust max-age based on post age (older = longer cache)\n $post_age = time() - get_post_time('U');\n $max_age = ($post_age > WEEK_IN_SECONDS) ? 600 : 120;\n header(\"Cache-Control: public, max-age={$max_age}, s-maxage=86400, stale-while-revalidate=3600\");\n return;\n }\n\n // Static pages: long cache\n if (is_page()) {\n header('Cache-Control: public, max-age=600, s-maxage=86400, stale-while-revalidate=7200');\n return;\n }\n\n // Front page: shorter cache (frequently changing)\n if (is_front_page()) {\n header('Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=120');\n return;\n }\n\n // Default: conservative public cache\n header('Cache-Control: public, max-age=60, s-maxage=600');\n}\n"})})}),"\n",(0,r.jsx)(i.A,{children:(0,r.jsx)(n.p,{children:"The post-age-based cache differentiation \u2014 shorter browser cache for recent posts, longer for older ones \u2014 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."})}),"\n",(0,r.jsx)(n.hr,{}),"\n",(0,r.jsx)(n.h2,{id:"cdn-cache-invalidation-on-post-update",children:"CDN cache invalidation on post update"}),"\n",(0,r.jsx)(d.A,{label:"CDN cache purge on post save",children:(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-php",children:"add_action('save_post', 'cc_purge_on_post_save', 10, 2);\n\nfunction cc_purge_on_post_save(int $post_id, WP_Post $post): void {\n // Skip autosaves and revisions:\n if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {\n return;\n }\n // Skip non-published posts:\n if ($post->post_status !== 'publish') {\n return;\n }\n\n $url = get_permalink($post_id);\n // Trigger CDN purge via API \u2014 implementation varies by CDN provider:\n cc_purge_cdn_url($url);\n\n // Also purge archive/index pages that list this post:\n cc_purge_cdn_url(home_url('/'));\n if ($categories = get_the_category($post_id)) {\n foreach ($categories as $cat) {\n cc_purge_cdn_url(get_category_link($cat->term_id));\n }\n }\n}\n"})})}),"\n",(0,r.jsxs)(n.p,{children:["The CDN purge implementation (",(0,r.jsx)(n.code,{children:"cc_purge_cdn_url"}),") is provider-specific \u2014 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."]}),"\n",(0,r.jsx)(n.hr,{}),"\n",(0,r.jsx)(n.h2,{id:"pitfalls",children:"Pitfalls"}),"\n",(0,r.jsx)(h.A,{children:(0,r.jsxs)(n.p,{children:["The most common mistake is setting a long ",(0,r.jsx)("code",{children:"max-age"})," without also setting ",(0,r.jsx)("code",{children:"s-maxage"}),", then expecting the CDN to respect ",(0,r.jsx)("code",{children:"max-age"})," for edge caching. The ",(0,r.jsx)("code",{children:"max-age"})," directive controls browser cache; CDNs typically honour ",(0,r.jsx)("code",{children:"s-maxage"})," for their edge cache lifetime if it's present, or fall back to ",(0,r.jsx)("code",{children:"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."]})}),"\n",(0,r.jsx)(l.A,{children:(0,r.jsxs)(n.p,{children:["WordPress's ",(0,r.jsx)("code",{children:"nocache_headers()"})," function \u2014 called internally for admin pages, login screens, and WooCommerce cart pages \u2014 adds aggressive no-cache headers including ",(0,r.jsx)("code",{children:"Cache-Control: no-cache, must-revalidate, max-age=0"})," and also sets ",(0,r.jsx)("code",{children:"Pragma: no-cache"}),". If any plugin or theme calls ",(0,r.jsx)("code",{children:"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 ",(0,r.jsx)("code",{children:"send_headers"})," to fire after other plugins' header manipulation."]})}),"\n",(0,r.jsx)(c.A,{children:(0,r.jsxs)(n.p,{children:["The ",(0,r.jsx)("code",{children:"stale-while-revalidate"})," directive was not widely supported by CDNs until around 2020. Modern CDN edge networks \u2014 Cloudflare, Fastly, CloudFront \u2014 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."]})}),"\n",(0,r.jsx)(u.A,{links:[{title:"Developer Notes",href:"/devel/"},{title:"Apache mod_brotli",href:"/webdev/httpd-mod-brotli/"},{title:"Web Performance \u2014 Topic Hub",href:"/topic/web-performance/"}]})]})}function _(e={}){const{wrapper:n}={...(0,a.R)(),...e.components};return n?(0,r.jsx)(n,{...e,children:(0,r.jsx)(v,{...e})}):v(e)}},5894(e,n,t){t.d(n,{A:()=>r});t(6540);var s=t(4848);function r({label:e,children:n}){return(0,s.jsxs)("div",{className:"sf-command",children:[e&&(0,s.jsx)("div",{className:"sf-command__label",children:e}),n]})}},1583(e,n,t){t.d(n,{A:()=>r});t(6540);var s=t(4848);function r({children:e}){return(0,s.jsxs)("div",{className:"sf-callout sf-callout--pitfall",children:[(0,s.jsx)("div",{className:"sf-callout__title",children:"\u26a0 Common Pitfall"}),(0,s.jsx)("div",{children:e})]})}},5206(e,n,t){t.d(n,{A:()=>r});t(6540);var s=t(4848);function r({children:e}){return(0,s.jsxs)("div",{className:"sf-callout sf-callout--compat",children:[(0,s.jsx)("div",{className:"sf-callout__title",children:"\u2699 Compatibility Note"}),(0,s.jsx)("div",{children:e})]})}},5914(e,n,t){t.d(n,{A:()=>r});t(6540);var s=t(4848);function r({links:e}){return(0,s.jsxs)("div",{className:"sf-further-reading",children:[(0,s.jsx)("div",{className:"sf-further-reading__title",children:"Further Reading"}),(0,s.jsx)("ul",{children:e.map((e,n)=>(0,s.jsx)("li",{children:(0,s.jsx)("a",{href:e.href,children:e.title})},n))})]})}},2299(e,n,t){t.d(n,{A:()=>r});t(6540);var s=t(4848);function r({children:e}){return(0,s.jsxs)("div",{className:"sf-callout sf-callout--observed",children:[(0,s.jsx)("div",{className:"sf-callout__title",children:"\u2b21 Observed Behaviour"}),(0,s.jsx)("div",{children:e})]})}},9826(e,n,t){t.d(n,{A:()=>r});t(6540);var s=t(4848);function r({children:e}){return(0,s.jsxs)("div",{className:"sf-callout sf-callout--answer",children:[(0,s.jsx)("div",{className:"sf-callout__title",children:"\u2192 Short Answer"}),(0,s.jsx)("div",{children:e})]})}},7533(e,n,t){t.d(n,{A:()=>r});t(6540);var s=t(4848);function r({then:e,now:n}){return(0,s.jsxs)("div",{className:"sf-then-now",children:[(0,s.jsxs)("div",{className:"sf-then-now__panel sf-then-now__panel--then",children:[(0,s.jsx)("div",{className:"sf-then-now__label",children:"The
1n"}),(0,s.jsx)("div",{children:e})]}),(0,s.jsxs)("div",{className:"sf-then-now__panel sf-then-now__panel--now",children:[(0,s.jsx)("div",{className:"sf-then-now__label",children:"Now"}),(0,s.jsx)("div",{children:n})]})]})}},2172(e,n,t){t.d(n,{A:()=>r});t(6540);var s=t(4848);function r({children:e}){return(0,s.jsxs)("div",{className:"sf-callout sf-callout--changed",children:[(0,s.jsx)("div",{className:"sf-callout__title",children:"\u21bb What Changed"}),(0,s.jsx)("div",{children:e})]})}},8453(e,n,t){t.d(n,{R:()=>o,x:()=>i});var s=t(6540);const r={},a=s.createContext(r);function o(e){const n=s.useContext(a);return s.useMemo(function(){return"function"==typeof e?e(n):{...n,...e}},[n,e])}function i(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(r):e.components||r:o(e.components),s.createElement(a.Provider,{value:n},e.children)}}}]);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.