Why your caching plugin keeps serving old CSS after a deploy

You deploy a CSS change, clear the cache, and nothing changes. Usually it's a cache you didn't know you had. How to find it, and how to stop it happening again.

You change a stylesheet, deploy it, clear the cache, and the site looks exactly the same. You open the CSS file directly and your change is there. The page still doesn’t show it.

I hit this recently on a site running WP Rocket behind Cloudflare. A new rule was in the theme’s stylesheet on the server, but visitors were getting a version from days earlier. Here’s why it happens and how to stop it for good.

There’s more than one cache

“Clear the cache” sounds like one action, but a typical WordPress site has several caches stacked on top of each other:

  1. The page cache. WP Rocket (or W3 Total Cache, LiteSpeed and so on) saves each page’s finished HTML and serves it without running WordPress.
  2. The optimised-assets cache. If the plugin minifies or combines CSS and JavaScript, it writes new copies of your files to its own folder, such as wp-content/cache/min/, and rewrites the page to point at them.
  3. The CDN. Cloudflare keeps its own copy of static files at the edge, keyed by the full URL, query string included.
  4. The browser. With long cache headers, the visitor’s browser won’t even ask for the file again.

Each layer can hold a stale copy, and clearing one doesn’t necessarily clear the others.

Find out which copy you’re getting

Before changing anything, look at the page source and find the stylesheet’s <link> tag. On the site in question it looked like this:

<link rel="stylesheet"
  href="/wp-content/cache/min/1/wp-content/themes/mytheme/dist/css/style.css?ver=1791034576">

Two clues here. The path starts with wp-content/cache/min/, so the page isn’t loading the theme’s stylesheet at all. It’s loading WP Rocket’s minified copy. And that copy’s ver is an old timestamp, from before the change.

Then compare with an uncached request. Add a throwaway query string, such as ?nocache=123, which most page caches skip, and check the same tag:

<link rel="stylesheet"
  href="/wp-content/themes/mytheme/dist/css/style.css?ver=1791121181">

Fresh HTML points at the new file. Cached HTML points at the stale minified copy. That narrows it down to the first two layers.

You can check the CDN layer with curl -I and look for cf-cache-status: HIT and the age header on the stylesheet URL.

Why the minified copy goes stale

When a plugin minifies your stylesheet, the file it serves is its own. Your theme can version its stylesheet carefully, for example with the file’s modification time:

wp_enqueue_style(
	'mytheme',
	get_theme_file_uri( 'dist/css/style.css' ),
	array(),
	filemtime( get_theme_file_path( 'dist/css/style.css' ) )
);

But the page doesn’t link to that URL any more. It links to the plugin’s copy, and the plugin decides when that copy is rebuilt. A page-cache purge alone, such as purging from code with rocket_clean_domain(), empties the saved HTML but can leave the minified files in place. The freshly cached pages then point straight back at the old minified CSS.

The fix: don’t minify what’s already minified

If your theme has a build step, its stylesheet is probably already compressed. Running it through a second minifier gains a few bytes at best and adds a cache you have to remember to clear. Exclude it:

/**
 * Keep WP Rocket's minifier off the theme stylesheet. The build already
 * compresses it, and the enqueue versions it by modification time.
 */
function mytheme_rocket_exclude_css( $excluded ) {
	$excluded[] = wp_make_link_relative( get_theme_file_uri( 'dist/css/style.css' ) );
	return $excluded;
}
add_filter( 'rocket_exclude_css', 'mytheme_rocket_exclude_css' );

Now pages link to the theme’s own file with its own ?ver=. Every deploy that changes the file changes the URL, so Cloudflare and browsers fetch it fresh, and a normal page-cache clear is enough to get new HTML pointing at it.

Other plugins have an equivalent setting: “Exclude CSS files” in LiteSpeed Cache and Autoptimize, or the minify exclusions in W3 Total Cache.

Version every URL the CDN sees

The same idea applies to anything your theme loads without going through wp_enqueue_*():

  • Favicons and web manifests printed by hand in header.php. Add ?v= plus the file’s modification time.
  • JSON or data files fetched by JavaScript.
  • JavaScript modules imported by other modules. A plain import './nav.js' never changes its URL, so the CDN can serve an old copy indefinitely. I’ll cover a build-step fix for that in a later post.

If the URL changes when the content changes, it doesn’t matter how long anything caches it.

Checklist for “my change isn’t showing”

  1. View the page source. Is the stylesheet URL the theme’s own, or a cache or minify path?
  2. Load the page with a throwaway query string. Does the fresh HTML look right?
  3. Check the asset URL with curl -I. Is the CDN returning an old copy?
  4. Exclude already-built assets from plugin minification.
  5. Make sure every asset URL changes when its content changes.

Most “the cache won’t clear” problems turn out to be a cache you didn’t know you had. If you’re stuck on one, it’s the kind of thing I sort out as part of maintenance.

Written by

James Knowles

Started in 2021 as an apprentice on a support desk, answering clients directly, then worked through agency teams on WordPress, WooCommerce and Shopify builds. Now leads Ramosa and every project.

More from James

Got a WordPress project?