TL;DR - SemVer your app and generate a meta.json
file on each build that won't be cached by the browser. Invalidate cache and hard reload the app when there's a version mismatch.
Note: The examples and explanations in this post are React based. But the strategy will work with any web application/framework.
As great as caching is — cache invalidation has been a struggle for a long time now. Invalidating the cache of a web app that's loaded in the browser is hard. But invalidating the cache of a web app that's saved to the home screen is even harder.
A quick intro to caching —
Server caching: Web servers cache the resources when they are requested for the first time. Second time onwards, the resources are served from the server cache. There's a lot more to this — CDN, origin servers, edge servers, etc but we'll not go into all that. Invalidating server cache is quite straight forward as we have control over our server and on each new deploy, we could either automatically or manually clear the old cache.
Browser caching: Browsers also cache the resources in their own way. When a site is loaded for the first time in the user's browser, the browser decides to cache some resources (mostly assets like images, js and css) locally and the next time the user visits the same site, the browser serves the resources from the local cache. Since we don't have control over the user's browser, clearing cache in the user's browser has always been a bit of a struggle in the past. With cache headers and with build tools like webpack generating unique chunks on each build, it's a becoming a bit easier to manage, but still, it's not without pitfalls.
Here are some of the gotchas with browser caching —
- Browsers tend to ignore cache validation some times if the site is refreshed in the same tab — if the user pins the tab, there's a good chance the site will be loaded from browser cache even if the server cache is cleared.
- If your app is registering a service-worker, then the service worker cache will be invalidated only if the user opens the site in a new tab. The user will be stuck with the service worker cache forever if the tab is never closed.
- If the user adds the site to home screen in mobile/tablet, then the browser cache will be invalidated only if the user explicitly quits the app — it's almost the same as having the same tab open in the browser. I know people who don't quit their home screen apps for months.
Ideally, caching helps to load the site faster. Disabling cache is not the answer. It's also not reliable as you cannot control the behavior of your user's browser. We want to figure out a way to clear the browser or service worker cache every time a new version of our app is deployed to the server.
A simple yet effective approach
- SemVer your deploys
- Bundle the app version into the app
- Generate a
meta.json
file with the app version on each build - Fetch
meta.json
on load and compare versions - Force clear cache and hard reload when there's a version mismatch
SemVer your deploys
Version all your deploys with SemVer. I personally use these three npm commands that automatically increments the package version and creates a git commit along with a corresponding version tag.
-
npm version patch
— for releases with only bug fixes -
npm version minor
— for releases with new features w/ or w/o bug fixes -
npm version major
— for major releases or breaking features
Remember to push your commit with --tag
attribute — git push origin master --tags
Bundle the app version into the app
Parse the package version during webpack build (or relevant build tool) and set a global variable in the app so you can conveniently check the version in the browser console as well as use this to compare with the latest version.
import packageJson from '{root-dir}/package.json';
global.appVersion = packageJson.version;
Once this is set, you will be able to check the app version in the browser console by typing appVersion
.
Generate a meta.json
file with the app version on each build
Run a script to generate a meta.json
file in the public
dir of your app.
Add a prebuild
npm script that will generate the meta.json
file before each build
.
/* package.json */
{
"scripts": {
"generate-build-version": "node generate-build-version",
"prebuild": "npm run generate-build-version",
// other scripts
}
}
/* generate-build-version.js */
const fs = require('fs');
const packageJson = require('./package.json');
const appVersion = packageJson.version;
const jsonData = {
version: appVersion
};
var jsonContent = JSON.stringify(jsonData);
fs.writeFile('./public/meta.json', jsonContent, 'utf8', function(err) {
if (err) {
console.log('An error occured while writing JSON Object to meta.json');
return console.log(err);
}
console.log('meta.json file has been saved with latest version number');
});
After each build, once you deploy the app, meta.json
can be accessed using the path /meta.json
and you can fetch the json like a REST endpoint. It won't be cached by the browser as browsers don't cache XHR requests. So you will always get the latest meta.json
file even if your bundle files are cached.
So if the appVersion
in your bundle file is less than the version
in meta.json
, then we know that the browser cache is stale and we will need to invalidate it.
You can use this script to compare semantic versions —
// version from `meta.json` - first param
// version in bundle file - second param
const semverGreaterThan = (versionA, versionB) => {
const versionsA = versionA.split(/\./g);
const versionsB = versionB.split(/\./g);
while (versionsA.length || versionsB.length) {
const a = Number(versionsA.shift());
const b = Number(versionsB.shift());
// eslint-disable-next-line no-continue
if (a === b) continue;
// eslint-disable-next-line no-restricted-globals
return a > b || isNaN(b);
}
return false;
};
You can also find this code in my GitHub example
Fetch meta.json
on load and compare versions
When the App
is mounted, fetch meta.json
and compare the current version with the latest version in the server.
When there is a version mismatch => force clear cache and hard reload
When the versions are the same => Render the rest of the app
I have built a CacheBuster
component that will force clear cache and reload the site. The logic will work for most of the sites but can be tweaked for custom cases depending on the applications.
/* CacheBuster component */
import packageJson from '../package.json';
global.appVersion = packageJson.version;
const semverGreaterThan = (versionA, versionB) => {
// code from above snippet goes here
}
export default class CacheBuster extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: true,
isLatestVersion: false,
refreshCacheAndReload: () => {
console.log('Clearing cache and hard reloading...')
if (caches) {
// Service worker cache should be cleared with caches.delete()
caches.keys().then(function(names) {
for (let name of names) caches.delete(name);
});
}
// delete browser cache and hard reload
window.location.reload(true);
}
};
}
componentDidMount() {
fetch('/meta.json')
.then((response) => response.json())
.then((meta) => {
const latestVersion = meta.version;
const currentVersion = global.appVersion;
const shouldForceRefresh = semverGreaterThan(latestVersion, currentVersion);
if (shouldForceRefresh) {
console.log(`We have a new version - ${latestVersion}. Should force refresh`);
this.setState({ loading: false, isLatestVersion: false });
} else {
console.log(`You already have the latest version - ${latestVersion}. No cache refresh needed.`);
this.setState({ loading: false, isLatestVersion: true });
}
});
}
render() {
const { loading, isLatestVersion, refreshCacheAndReload } = this.state;
return this.props.children({ loading, isLatestVersion, refreshCacheAndReload });
}
}
And we can use this CacheBuster
component to control the render in App
component
/* App component */
class App extends Component {
render() {
return (
<CacheBuster>
{({ loading, isLatestVersion, refreshCacheAndReload }) => {
if (loading) return null;
if (!loading && !isLatestVersion) {
// You can decide how and when you want to force reload
refreshCacheAndReload();
}
return (
<div className="App">
<header className="App-header">
<h1>Cache Busting - Example</h1>
<p>
Bundle version - <code>v{global.appVersion}</code>
</p>
</header>
</div>
);
}}
</CacheBuster>
);
}
}
You can also find the code for both these components here —
CacheBuster - CacheBuster.js
App - App.js
Force clear cache and hard reload when there's a version mismatch
Every time the app is loaded, we check for the latest version. Depending on whether the app version is stale or not, we can decide to clear cache in different ways.
For instance,
- You can hard-reload before rendering the app
- You can show a modal/popup asking the user to click a button and trigger a hard-reload
- You can hard-reload when the app is idle
- You can hard-reload after a few seconds with
setTimeout()
You can find the entire code from this post with a working example in this repo — cache-busting-example
That's all folks. If you have any feedback for this approach (good and bad), do let me know in the comments.
Cache busting is fun. 🎉
Top comments (58)
Hi Dinesh, good article.
I was wondering though, why would you need a cache-invalidation mechanism. We are developing with angular, but I believe, you can setup things in the same way with React.
We have also been struggling with browser caching, because SEO loves resources cached for as long as possible. So here is our current setup:
js
andcss
files all have a hash part in their filename, so we cache them forever (366 days to be precise), but if they change, the filename changes, so no trouble there.index.html
- never cache it. Here we have references tojs
andcss
files.This way, SEO performance score is happy that we cache everything forever, but also, we never have to deal with invalid data.
I am not trying to sell my approach here, I am just honestly curious what is your use case for such cache invalidation mechanism? Thanks.
Hey Eddy — Good question.
We definitely need to leverage browser and server cache (PWA service worker implementation for cache control is a lot better).
But there are still a few gotchas we haven't solved yet. These "recommended" ways work most of the time but in some rare cases, they won't as I highlighted at the beginning of the post.
So this technique is more of taking control of cache "within your app code" and use this as a fallback approach when all else fails.
We have a peculiar case where I work — safari web app pinned to the home screen and users won't quit it for a few months (restaurant iPads). We simulated native app behavior with PWAs but cache busting instantly after a new deploy was tricky. This technique eventually helped us.
The service worker that comes with react does cause scenarios where the cache is not busted that feel a bit weird. For that reason it was removed from create-react-app a while ago, as discussed here github.com/facebook/create-react-a...
The mechanism @eddyp23 mentions works perfectly and it's supported by default by create-react-app create-react-app.dev/docs/producti...
@eddyp23 I am using React but as you mentioned the two are probably largely the same in terms of setup. I am wondering how you would set up the caching for js and css files, do those have to be individually referenced in index.html?
I think it should be noted the
window.location.reload(true)
will only disable the cache for the current page. Any subsequent requests (async js/css, other pages, images, etc.) will not be cache busted. It's also very dependant on the browser. Chrome for example hangs on to cache for dear life unless instructed by headers to do otherwise. The best way to ensure cache is busted is to fingerprint filenames based on the files content and to never cache pages (e.g. index) so references remain fresh.James — That's very true. Browsers sometime decide to ignore
window.location.reload(true)
.But
caches.delete()
will always delete the cache. So reloading synchronously aftercache.delete()
should clear the cache for the user.You mention reloading synchronously... I was having issues with infinite looping when our app updated and it seems due to
window.location.reload
firing before our caches had time to clear, in the code you shared for CacheBuster. Simply adding aPromise.all()
on thecaches.delete()
promises solved the issue in our case.Thank you for taking the time to share this!
Hi Matt,
We are facing the same issue, can you please let me know the exact code you have used to solve this issue?
Something like this? Please correct me if I am wrong.
caches.keys().then(async function(names) {
await Promise.all(names.map(name => caches.delete(name)));
});
Yep, I used promises but that's pretty much exactly what I did, then do the window.location.reload after your await
Thanks, I got it now.
Hi a-ssassi-n,
I'm having the same issue and I tried to do it the same way as this:
refreshCacheAndReload: () => {
if (caches) {
caches.keys().then(async function(names) {
await Promise.all(names.map(name => caches.delete(name)))
})
}
window.location.reload(true)
},
And it keeps happening did I miss something?, can you please give me any guide?
This is a really great solution and very well explained in this article! There's just one problem: location.reload(true) is deprecated! Calling location.reload() is still in the spec, but passing true to force the browser to get the page again and bypass the cache is deprecated. And because it's deprecated, some browsers are starting to ignore it. As a result, we are seeing an infinite loop where our browser keeps reloading the same page from cache. Have you thought about an alternative that will achieve the same result?
Hey, any updates on this?
Hi Dinesh, thanks for the great article. I found it very helpful and I use it for a project my team is working on. At a point, you say: "It won't be cached by the browser as browsers don't cache XHR requests." Well, for some reason my browser is catching it.
I will try
fetch(
/meta.json?${new Date().getTime()}
, { cache: 'no-cache' })as @ppbraam suggested dev.to/ppbraam/comment/gdac
If you have any other idea that may help I would appreciate it. Thanks in advance.
Hi Dinesh,
In my case the fetch('/meta.json') does not work. How it will tyake the meta json from public folder.
Hi, are you able to figure out meta.json file, I am also facing the same issue ::
Failed to load resource: the server responded with a status of 404 (Not Found)
Cannot GET /meta.json
What web server are you fetching from? I noticed the same on IIS as asp.net core protects json and thinks they're a config file so serves up a 404. Changing the file extension to txt (meta.txt) resolved that issue.
This is stellar. Looking forward to giving this a shot. Thank you Dinesh!
Dinesh, how would this work with a functional component? I tried to translate it but it just keeps refreshing over and over. Here is my FC version, using Typescript
Hi Dinesh,
Good work.
I am having an issue when the metal.json version not match package.json version it goes to infinit loop because the meta.json is not updated.
Any suggestion ?
Thanks
Nic
I had a same problem. Is this code ever the versions will differents, the package.json version and meta.json version. In this case, how I update the meta.json version after realoading and cleaning cache?
If you could create a sample repo that reproduces the problem I can take a look and see what's happening.
OK I will try.
I also got this error when i manually refresh the page after new version has been updated in server :
service-worker.js:1 Couldn't serve response for http Error: The cached response that was expected is missing.
// While Implementing these code I am stuck into the infinite loop
// can anyone help me to where I am commit the mistake
if (caches) {
const names = await caches.keys();
console.log(names);
await Promise.all(names.map((name)=>caches.delete(name)))
}
window.location.reload();
While Implementing these code I am stuck into the infinite loop
can anyone help me to where I am commit the mistake
if (caches) {
const names = await caches.keys();
console.log(names);
await Promise.all(names.map((name)=>caches.delete(name)))
}
window.location.reload();
Some comments may only be visible to logged-in visitors. Sign in to view all comments.