Skip to main content
ModPageSpeed 2.0: AVIF, WebP, and critical CSS — up to 69% less page weight on the live demo

A WordPress Server-Side Page Cache Plugin: Control Plane, Not Cache

By Otto van der Schaaf

caching wordpress architecture operations nginx ttfb

Hit an anonymous WordPress page through nginx with curl -I and read the response headers. You will almost certainly find no Cache-Control header at all. WordPress does not mark anonymous pages as publicly cacheable by default. That single fact is why a server-layer page cache sitting in front of WordPress so often does nothing: it sees a response it is not allowed to store, and forwards every request to PHP.

ModPageSpeed reads Cache-Control the way the spec says to. On 2.0, the freshness logic honors RFC 9111 directly: no-store and private make a response uncacheable at MISS time, and max-age=N is used as the TTL. There is no custom header protocol to learn. So if WordPress emitted Cache-Control: public, max-age=600 on an anonymous page, ModPageSpeed 2.0 would cache that page for ten minutes. It does not, so ModPageSpeed falls back to pagespeed_html_max_age, which defaults to 0, which means uncached.

The fix is not more server code. It is teaching WordPress to send the right header, and to tell the cache when to forget. That is the entire job of the WordPress server-side page cache plugin We-Amp is bringing to WordPress.org: WeAmp Cache Control for ModPageSpeed. It is currently in review at the directory, not yet listed, not yet GA. This post is about what it is architecturally, not an announcement that it has shipped.

A WordPress server-side page cache plugin that is a control plane, not a cache

The plugin is pure PHP, and it does not cache anything itself. It is a control plane: it sets cache headers on responses and calls a purge API when content changes. The caching happens server-side, in the ModPageSpeed module you already run on nginx or Apache.

That split is deliberate, and it is the same architecture LiteSpeed Cache uses on its own server: the WordPress plugin is a bridge, and the server does the actual storage. We-Amp took LiteSpeed Cache as a benchmark precisely because the model is proven. The difference is the layer underneath. LiteSpeed Cache drives LiteSpeed Web Server. WeAmp Cache Control drives ModPageSpeed, which runs on nginx, Apache, IIS, and Envoy. This is not a knock on LiteSpeed Cache, which is a strong plugin with millions of installs. It is a statement about where the cache lives.

It is also a different layer from the PHP-resident caches like W3 Total Cache and WP Rocket. Those generate cached HTML inside PHP and serve it back through PHP (or via a generated nginx/Apache rule set). They work, and on shared hosting where you cannot install a server module, they are often the only option. WeAmp Cache Control assumes you control your server stack, because ModPageSpeed has to be installed there. That assumption narrows the audience to VPS and dedicated operators, and it is the honest cost of putting the cache in the server instead of in PHP.

So the plugin has two responsibilities, and they map directly onto the two halves of any cache: writing and invalidating.

Setting the header: enabling the cache

On the request path, the plugin hooks wp_headers and adds Cache-Control: public, max-age=<ttl> to anonymous page responses. The TTL is configurable per post type in the admin UI, so your product pages and your blog posts can carry different lifetimes.

The interesting engineering is not adding the header. It is knowing when not to. A cached anonymous page served to a logged-in user is a correctness bug, and WordPress core does not protect you from it as reliably as people assume. Core only calls nocache_headers() on the frontend when the admin bar is showing. A logged-in subscriber who disabled the admin bar, or any site that calls show_admin_bar(false), gets no protective headers from core at all. So the plugin applies its own checks rather than trusting WordPress to have done it.

Cache-Control: public is withheld whenever is_user_logged_in(), is_admin(), is_feed(), is_search(), or is_404() is true; whenever the request path starts with /wp-json/, /wp-admin/, /xmlrpc.php, or /wp-login.php; whenever the response already carries a Set-Cookie; and whenever an unsupported Vary token is present. Logged-in users, cart cookies, and the other bypass cases instead get Cache-Control: no-store, evaluated against a configurable cookie bypass list (defaulting to wordpress_logged_in_*, woocommerce_cart, woocommerce_session_*, comment_author_*, and wp-settings-*) before any public header is even considered.

WooCommerce gets explicit handling because WooCommerce’s own headers are not enough. Its prevent_caching() emits no-cache, must-revalidate, max-age=0 (not no-store) for cart, checkout, and account pages, and it does not cover product pages for users with items in cart, Blocks-based cart and checkout, or Subscriptions pages. The plugin calls is_cart(), is_checkout(), and is_account_page() itself and emits no-store regardless.

There is one server-side prerequisite the plugin cannot do from PHP, and the documentation is blunt about it. ModPageSpeed 2.0’s cache key is scheme plus hostname plus URL, with no cookie variation. Without an nginx-level bypass, a cached anonymous response can still be served to a logged-in user inside the TTL window, because the cache does not know the cookie changed. So the operator adds a map block keyed on $http_cookie and wires proxy_no_cache / proxy_cache_bypass on the ModPageSpeed location. The setup wizard hands you the exact snippet and verifies it is active. This is real activation friction: five to seven steps versus two for a pure-PHP cache. The plugin does not pretend otherwise.

Purging the header: invalidating the cache

On the write path, the plugin calls the ModPageSpeed purge API when content changes. The primary hook is transition_post_status, which fires on the publish transition and on edits to already-published posts, where the narrower publish_post would miss the update case. It also hooks delete_post, taxonomy and term changes, comment changes, theme switches, nav-menu and widget option updates, and WooCommerce stock changes (which do not run through transition_post_status at all). The purge scope is the exact post URL plus the homepage plus the affected archive and feed URLs.

Against 2.0 the call is a POST to the worker’s <admin-base>/v1/cache/purge endpoint with a JSON body naming the absolute URL. The admin base is operator-configured, not a fixed top-level path. Bearer auth is sent only when the worker has an API token configured, so the plugin does not hard-require a key. Every purge target is derived from get_permalink() and home_url() and normalized — the plugin never accepts URL components from request parameters, and it refuses wildcard values outright as a contract-wide guard. On 2.0 there is no wildcard purge at all: a single-URL purge is exact-match, and a full flush takes a separate, explicitly confirmed request. The refusal still matters because on 1.1 a trailing * flushes the entire cache, so the plugin never sends one against either line. For deeper context on why one purge touches more than one cache entry, see What Happens When You Purge One URL.

The 1.1 line needs a clear caveat. mod_pagespeed 1.1 does not cache HTML pages. It is an in-process streaming rewriter; its cache holds optimized images, CSS, and JavaScript, not full pages. Worse, with the default ModifyCachingHeaders on, 1.1 will overwrite the plugin’s Cache-Control: public on rewritten HTML with max-age=0, no-cache. So on 1.1 the plugin is a purge-only control plane: its purge calls evict optimized sub-resources, not pages. If you want full-page caching on 1.1, you put an external cache in front (nginx proxy_cache, Varnish, or a CDN) and set ModifyCachingHeaders off so the plugin’s headers reach that front cache. That is documented as an advanced recipe, not a default claim. On 2.0, the module caches HTML itself and no front cache is required.

The plugin auto-detects which line is running and adjusts. The same setup wizard and purge contract cover both engines, which is the point of putting the control plane in WordPress and the cache in the server.

If you already run ModPageSpeed on a VPS or dedicated box, the missing piece is usually not optimization — it is that anonymous pages never get marked cacheable, so the cache never engages. The plugin closes that gap from inside WordPress without a line of server code beyond one nginx map block. It is free and GPL-2.0, and it is coming to WordPress.org once the directory review clears; until then you install the zip directly. The server module it drives is a separate matter: ModPageSpeed needs a commercial license for production use, but enforcement is soft and the software never locks you out. Start with the module via the install guide, then read the Cache-Control behavior so you know exactly what the plugin’s headers will do once they arrive.


mod_pagespeed and PageSpeed are trademarks of Google LLC; We-Amp B.V. is not affiliated with, endorsed by, or sponsored by Google, and maintains the open-source mod_pagespeed project independently.

Like this kind of writeup?

We write about how mod_pagespeed and ModPageSpeed actually work, and what we learn shipping them. Get the next post by email.

Read next