DEV Community

Cover image for New writer, old reader — when your cache-buster doesn't bust the bundle

New writer, old reader — when your cache-buster doesn't bust the bundle

We shipped a release that renamed a JavaScript global. The PHP that writes the global was updated. The JS that reads it was updated. Both files were on the server. The feature did nothing.

Page source showed the new global being written. The bundle being loaded still contained the old reader. New writer, old reader, no error, no console warning, no clue.

This is a specific trap, but the shape is general enough to be worth knowing whatever framework you are in: an asset cache-buster only busts the cache it addresses. If there is a second cache layer between you and the browser, and it is keyed on something you did not change, your version parameter is decorative.

Layer one: the browser cache

Register a front-end asset with no version and you get a plain tag with no query string:

<script src="modules/mymodule/views/js/upload.js"></script>
Enter fullscreen mode Exit fullscreen mode

Static assets on most hosts are served with far-future Cache-Control. So after you fix a JS bug, sync files and clear the framework cache, returning visitors keep running the old file until they hard-refresh. New visitors get the fix. That asymmetry is why a "still broken" report can be entirely browser cache, and why it is worth confirming before you debug anything:

fetch(url, { cache: 'no-store' }).then(r => r.text()).then(t => console.log(t.slice(0, 200)));
Enter fullscreen mode Exit fullscreen mode

If that shows the new file, the deployed file is fine and you are looking at a cache problem, not a code problem.

The fix in PrestaShop is a dedicated parameter, not a hand-built query string:

$controller->registerJavascript(
    'modules-mymodule-upload',
    'modules/' . $this->name . '/views/js/upload.js',
    ['position' => 'bottom', 'priority' => 500, 'version' => $this->version]
);
Enter fullscreen mode Exit fullscreen mode

JavascriptManager::add() appends ?<version> after it has resolved the path on disk.

The trap inside the fix

Do not put the query string in the path yourself:

// broken
$controller->registerJavascript($id, 'modules/x/views/js/upload.js?v=2.1.0', [...]);
Enter fullscreen mode Exit fullscreen mode

AbstractAssetManager::getFullPath() runs file_exists() on the raw relative path before any version handling. upload.js?v=2.1.0 does not exist as a file, so the asset is never registered at all — silently. No tag, no error.

A related caution from a live test: Media::getMediaPath(), used by the legacy addJS()/addCSS() path, does strip the query before its own file_exists(), so reading the core source suggests a manual ?v= is safe there. On an actual PrestaShop 9.1.4 shop it was not: adding ?v=<version> to addJS() inside a hookDisplayHeader() made the browser stop requesting the file entirely — zero requests in the Network tab, while other output from the same hook rendered fine. We reverted it and never fully isolated the cause. The lesson is not about that one API; it is that a correct-looking static read of framework source is not a substitute for a live test on the target version.

Layer two: the combined bundle, which the version does not touch

With version in place we shipped the rename. It still did not work.

PrestaShop's CCC ("combine, compress, cache") option concatenates all the page's JS into one bundle. CccReducer::reduceJs() names that bundle:

<position>-<sha1(joined file paths)><PS_CCCJS_VERSION>.js
Enter fullscreen mode Exit fullscreen mode

Read that carefully. The hash is over the file paths, not the file contents. And JavascriptManager::add() stores the version-less $srcPath as the asset's path.

So:

  • changing a file's contents does not change the bundle name;
  • changing the version parameter does not change the bundle name either;
  • the reducer checks filesystem->exists() on that name, finds the old bundle, and serves it.

Forever. There is no expiry.

Observed live: the page HTML emitted the new csfNumOfAttachments global from the new PHP, while bottom-3fc480223.js still contained the previous contact-upload.js reading window.numofattachments. Both files on disk were correct. The bundle in between was a fossil.

The fix that actually covers both layers

When a release changes the content of a front-end JS or CSS file, the upgrade script has to invalidate the bundle explicitly:

function upgrade_module_1_3_0($module)
{
    // Clears themes/<theme>/assets/cache/* and bumps
    // PS_CCCJS_VERSION / PS_CCCCSS_VERSION.
    Media::clearCache();

    return true;
}
Enter fullscreen mode Exit fullscreen mode

You need both:

  • 'version' handles the non-combined case (CCC off, or assets served individually).
  • Media::clearCache() handles the combined case.

Neither one covers the other.

Why this class of bug is expensive

It does not look like a caching problem. It looks like your feature is broken. The writer and the reader are both correct in the repository, both correct on disk, and disagree only in the artefact the framework assembled between them. Every instinct says "read the code again", and the code is fine.

Two habits that shorten it:

  1. When a fix does not take effect, verify what is actually being served before re-reading your source. Fetch the deployed asset with cache: 'no-store'. Look at the bundle, not the file.
  2. Treat "rename a shared symbol" as a deployment problem, not just a code change. Any rename that splits a writer from its reader across two files will fail this way if either side is stale. If you can, ship the change so the reader tolerates both names for one release. Then the fossil bundle is merely old, not broken.

Neither is exotic. Both are things we now do because the alternative cost us a release.


From maintaining around sixty PrestaShop modules at MEG Venture. The specifics above are PrestaShop 1.7 through 9, but any framework with an asset pipeline that concatenates by path has the same hole.

Top comments (0)