DEV Community

Murtaza Nathani
Murtaza Nathani

Posted on • Edited on

How to Cache bust web app

Why do we need to do cache busting ?

Static file gets cached and can be stored for a long period of time before it ends up expiring. So in order to get the latest features and fixes we need to remove the cache so that browsers can get the latest updates.

Additionally, have you ever felt web application like soundcloud, facebook etc..., asking to reload or upgrade after deployment, no right ? how the hell are they doing the updates ?


Making sure we have cache invalidated in CDN

Cache busting solves the CDN caching issue by using a unique file version identifier.
As discussed in the previous article we used no-cache policies on some files to bust the cache using unique files names. Hence we are sure that cloudfront will always keep the updated files..


Lets bust the browser cache

So today will cover what is the one of the best approach to seamlessly bust the cache of the frontend application in the browsers when a deployment is done, without the user feeling the app was upgraded...

The trick

The trick is we keep updating the version of the application in meta file, which never gets cached... and to seamlessly upgrade, we perform a reload on the route change so that user would feel as if they are redirecting to a different view, but in our case we are actually cache busting our application to get the new update from the build we deployed.

Let's dig in to see how its possible.

How to check if a new build is generated

To know when we have a new build in browsers we keep two version of the application.

  • package.json version
  • meta.json version

What are these version and how we manage it

Here is the command prebuild that runs before every build to manage both versions as shown below:



    "release": "HUSKY=0 standard-version",
    "prebuild": "npm run release && node ./build-version",


Enter fullscreen mode Exit fullscreen mode
  • package.json version is kept and maintain using tools like Semantic versioning or Standard release which upgrades the package version after every deployment. Here we are using standard-version to always get our package.json upgraded.

  • meta.json version is created in public folder using a script build-version.js we wrote to make sure we always get a latest version after deployment.

build-version.js:



const fs = require('fs');
const { version } = require('./package.json');

fs.writeFile('./public/meta.json', JSON.stringify({ version }), 'utf8', (error) => {
  if (error) {
    console.error('Error occurred on generating meta.json:', error);
    return;
  }
  // eslint-disable-next-line no-console
  console.info(`meta.json updated with latest version: ${version}`);
});


Enter fullscreen mode Exit fullscreen mode

The above scripts takes the latest version from package.json which was upgraded using npm run release and save it to meta.json using fs.writeFile.

Here is how the output of the above script will look like:

meta.json:



{ "version": "108.0.0" }


Enter fullscreen mode Exit fullscreen mode

Before we proceed to the next step, let me inform you that we are using the following frameworks in our app:

Code to check application is upgraded

We created a hook that could be placed in a suitable position in your application, preferably on layouts/routes:



import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { version } from '../../../package.json';

const useCacheBuster = () => {
  const location = useLocation();
  const parseVersion = (str) => +str.replace(/\D/g, '');

  useEffect(() => {
    fetch(`/meta.json?v=${+new Date()}`, { cache: 'no cache' })
      .then((response) => response.json())
      .then((meta) => {
        if (meta?.version) {
          const metaVersion = parseVersion(meta.version);
          const packageVersion = parseVersion(version);
          if (packageVersion < metaVersion) {
            if (window?.location?.reload) {
              window.location.reload();
            }
          }
        }
      })
      .catch((error) => {
        console.error('something went wrong fetching meta.json', error);
      });
  }, [location]);

  return null;
};

export default useCacheBuster;


Enter fullscreen mode Exit fullscreen mode

The hooks above is doing the following:

  1. useEffect having a deps of location, which runs on every change of the route.
  2. parseVersion is a pure function that can format the version like "1.0.5" into a number 105, so we can compare the versions.
  3. On changing the app route, the hook fires and fetches /meta.json files from the root of the app, important thing to note here is we are passing a date param: and cache, to make sure this file never returns the cached content on fetching.
  4. Using the response of meta.json we are checking if packageVersion is less than metaVersion, which means the new build has deployed and the browser is using the old cached build, so the app needs to reload.
  5. If the above condition is true then reload it!.

NOTE: if you are using a CDN then you need to cache bust in CDN by adding the meta.json to behaviours as shown here

P.S: we can optimize the fetching of meta, by conditionalizing it on certain routes rather then all.

That's it folks..., all you need to perform cache bust in browser programmatically.


Conclusion

The solution above is useful for the scenarios when you have a frequent deployment to production.
Moreover, in my understanding for apps used in webview or apps saved on homepage could also be similarly busted with different reload methods...


Please feel free to comment on the approach, would love to hear your feedback on this.

Regards.

Top comments (16)

Collapse
 
jonosellier profile image
jonosellier

What about running a script when you publish that takes all referred files (js, CSS, etc.) And renames them to be filename.hashOfContents.extension? Make a single change and the cache is automatically invalidated.

Collapse
 
mnathani profile image
Murtaza Nathani • Edited

That will work to invalidate cache in your CDN, but what about the users who are using the app and have cached the files in browser.. You can read more about it in the first series of this blog :)

They won't get an update until they manually refresh or close the browser. (which most of users don't do, as they prefer to suspend the system and continue from where they left in the browser)

Collapse
 
jonosellier profile image
jonosellier

My bad! I left out a final piece: don't cache your HTML for longer than maybe a day. Either it is an SPA and the bulk of the data is in JavaScript/APIs or it's a SSR page and you probably want them to see the latest data. I'd personally use a ServiceWorker to show the old cached page if the new page takes more than 1-2s to load.

The method I mentioned above has the benefit of serving the correct JS/CSS for each version of the page. Say I end up using a cached page: my JS and CSS is for that version of the page not the latest version of it. Obviously you need to keep the last n versions for it to work.

Thread Thread
 
mnathani profile image
Murtaza Nathani • Edited

Agreed, there's always more than one ways to do it.

Service workers are great, but they are not supported widely in all browser..

But it's great do control your cache.. would love to see your implementation if you can share, so that we can learn.

Collapse
 
volker_schukai profile image
Volker Schukai

interesting approach. The disadvantage is of course the loading of meta.json. Here you consume a request.

Collapse
 
mnathani profile image
Murtaza Nathani • Edited

Yeah agreed, that's a drawback..

In my live app I have optimized to check meta file only on certain routes to avoid hitting meta file on every route change..

Moreover, in my understanding hitting in on the same domain file, which would hardly few bytes is no harm. Considering if you have at least one deployment to production in a week..

Collapse
 
volker_schukai profile image
Volker Schukai

The bytes are not necessarily the bottleneck, but the connection. In the worst case you have a complete roundtrip. Of course it depends on the protocol.

RFC contains a few details:
w3.org/Protocols/rfc2616/rfc2616-s...

But that is already complaining on a high level.
For many, your solution should be a viable way.

Thread Thread
 
mnathani profile image
Murtaza Nathani

Hey thanks for sharing the link..

Yeah the complain is on the spot.

Collapse
 
husyn profile image
Husyn

A GitHub repo with all the code used in the series will be great

Collapse
 
mnathani profile image
Murtaza Nathani

That's a good idea, will post the repo for it

Thanks

Collapse
 
codewander profile image
codewander

I thought webpack has functionality to do this for you?

Collapse
 
mnathani profile image
Murtaza Nathani

Umm, I believe what you referring to is unique file names that webpack generates ?

Would love to learn how webpack breaks browser cache too ?

Collapse
 
codewander profile image
codewander

From my high level understanding, you use webpack to generate unique files names for all css and js dependencies, but rely on http cache headers to force browser to reload index.html periodically. (I could be wrong in my understanding). It's not as instant as your solution, since it relies on browser rather than interactively triggering reload directly inside of the react app. I would use your solution for thousands of user. I would use something simpler for hundreds of users.

Thread Thread
 
mnathani profile image
Murtaza Nathani

It consist of two things:

  1. Cache busting using unique files in your CDN. Work's if you reload the application manually and you'll find the new code running
  2. Using code to cache bust as discussed in the article. Works for auto reloading app, without users have to manually reload..
Collapse
 
kulkiratsingh profile image
Kulkirat Singh

This solution causes the app to be stuck in an infinite loop as it keeps on refreshing because packageVersion < metaVersion would be true always.

Collapse
 
mnathani profile image
Murtaza Nathani

No it won't be true always if you have set header's of meta.json file to have not cache using CloudFront policies.

Or manually settings headers for this to for no-store .

In this case it will always be a new version from next deployment.

Let me know if I was able to help ?