I had just deployed a handful of CSS fixes to a client site's staging: a line-alignment fix, some hardening on the fx-clip-up animation, and the removal of a dashed line nobody asked for. I opened staging, and the old behavior was still there. Every scroll animation played as one uniform fade-up, flat and monotone, even though the markup clearly mixed fx-mask, fx-scale, and fx-slide classes that were supposed to give each section its own entrance. My new CSS may as well have never shipped.
What made it stranger: the file on disk was correct. I opened the CSS on the server and it was the new version, byte for byte. I even compared the MD5 of the CSS actually being sent to the browser against the MD5 of the file on disk, and they matched. The server was sending the right bytes. My browser still showed the old animations.
Every cache was already flushed
My first reflex on a WordPress stack with LiteSpeed in front of it: purge the cache. I hit purge all in LiteSpeed. Refresh, still old. Then I remembered Cloudflare sits in front of LiteSpeed, so I purged Cloudflare too. Refresh, still old. I flushed the Redis object cache. Still. Every cache layer I knew about was gone, and that monotone animation stayed stubbornly on screen.
So I stopped and wrote down what I had already ruled out. Disk is correct. The bytes being served are correct, MD5 matches. LiteSpeed Page Cache is flushed. Cloudflare is purged. Redis is flushed. If the server is genuinely sending the new CSS and I still see the old one, there is exactly one place left that can be holding a stale copy: my own browser.
The root cause lives in the query string
In WordPress, assets get enqueued through wp_enqueue_style and wp_enqueue_script, and the version argument becomes a query string on the final URL:
wp_enqueue_style(
'theme-main',
get_stylesheet_directory_uri() . '/style.css',
array(),
THEME_VERSION
);Which renders in the HTML as something like:
<link rel="stylesheet" href="/wp-content/themes/mytheme/style.css?ver=4.4.0" />Here is the part that matters: browsers cache assets by their full URL, query string included. To a browser, style.css?ver=4.4.0 is one complete identity. As long as that URL does not change, the browser feels entitled to serve the copy it already stored without bothering to ask the server for a newer version. And THEME_VERSION in functions.php was still 4.4.0, exactly what it was before I deployed. The asset URL was identical to yesterday's.
That is why all my purging was pointless. The staging cache chain has four layers:
1. Browser cache -> keyed by URL + ?ver=
2. Cloudflare CDN
3. LiteSpeed Page Cache
4. Redis object cache (LiteSpeed)Purging LiteSpeed clears layers 3 and 4. Purging Cloudflare clears layer 2. But no purge button on the server can touch layer 1. The browser cache lives on the visitor's machine, and the only way to tell it that this is a new file is to change the URL.
There was one detail that should have been a red flag much earlier: this bug only happened to me. A fresh visitor who had never opened staging would download ?ver=4.4.0 for the first time and get the new CSS immediately, no problem. The only person suffering was me, the one who had refreshed a dozen times over the deploy session and had the old version sitting in local cache. The pattern of "broken only on my machine, everyone else is fine" is a classic tell for a browser cache problem, not a code bug.
The fix is a single line
The fix does not touch the CSS at all, because the CSS was already correct. I just bumped the version constant in functions.php:
// Before
define( 'THEME_VERSION', '4.4.0' );
// After
define( 'THEME_VERSION', '4.4.1' );Once deployed, every link and script tag automatically became ?ver=4.4.1. To the browser that is a brand-new URL it has never seen, so it is forced to fetch it fresh from the server. The animations that had been flat instantly rendered with their proper fx-mask, fx-scale, and fx-slide variety. Zero other code changes. Over that day's iterations I bumped it again to 4.4.2 for the next ship, and so on.
After that I adopted a simple rule: bump THEME_VERSION on every CSS or JS ship, no matter how trivial the change. One cheap line. The alternative is hours of confusion trying to tell whether it is stale cache or my fix actually broke something, which is exactly the trap I had just walked into.
Takeaways
- If new CSS or JS refuses to appear after every server cache is purged, suspect the browser cache keyed by
?ver=. - Eliminate first: check the file on disk, compare the MD5 the server sends against the disk file. If the server sends the right bytes but you still see the old ones, the browser is the suspect.
- Remember the layer order: browser -> Cloudflare -> LiteSpeed Page Cache -> Redis. Server-side purge buttons only reach layers 2 through 4.
- The "stale only in my browser, fresh visitors are fine" symptom is strong evidence of a browser cache, not a code bug.
- Bump the version constant (
THEME_VERSION) infunctions.phpon every asset ship, even trivial ones.wp_enqueue_styleturns it into?ver=Xand forces a fresh fetch. - Never rely on visitors to hard-refresh. Change the asset URL and the browser cache takes care of itself.
