The ultimate guide to cache-busting for React production applications

Max Shahdoost
8 min readJan 21, 2024

--

Cache-busting guide for React production

NOTE: We are NOT going through route-based lazy loading since it needs a whole other topic for itself so if you are not familiar with route-based lazy loading at all you need to get yourself some knowledge about it before reading this article.

What is the problem?

If you have been working as a Front-End engineer for quite some time, you’ve probably seen an error in your production deployment on a server implying that: Failed to fetch dynamically imported module or loading chunk XX failed! after deploying a new version of your web application.

Let’s take a moment to discuss the reasons behind this error and then take a deeper look at the multiple solutions for it.

You are probably using route-based lazy loading in your React application ( or other lib/frameworks ) if so then you need to read this before you break your production and damage the user experience!

Why is it happening?

React Production Chunk Load Error Flow

Assume you are using Webpack or Vite to build a production version of your React or Vue or any other Front-End framework or library application in a CI/CD flow, a user has opened the application before you trigger the CI/CD to deploy a new version of codes and a new version is now deployed.

As you may know, Vite and Webpack use a filename structure to create JavaScript, CSS, Images, Fonts, HTML, and other types of assets every time changes are applied inside even a single file in your codebase that is tracked. So every time you change a piece of code in any single file or more, if you try to build, the filename + hash code used for all files in your production build is changed.

For example, in Webpack we can configure it like the below:

output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].bundle.js',
},

and in Vite, we have:

  build: {
rollupOptions: {
output: {
chunkFileNames: `[name].[hash].js`,
},
},
},

The reason that we use a hash in filenames while generating assets and files in our production build is that we would want to let the browsers know that we have updated our code and it should remove the cache and download the new version of the application every time it has a new hash at the end of its name. Without the hash, the filename is always going to be the same and there is no way for browsers to be aware of changes and new versions.

So, if you name your Webpack or Vite chunks (JavaScript bundles) the same for each version, the browser doesn’t know the code has been updated. It sees xyz.js and thinks "I've already downloaded that, here it is!".

We’re going to use [contenthash] or [hash]to add a unique hash code based on the content. That means if the content of the code in that bundle changes so does the hash!

But that is not all the story, while we do the above to make the browsers aware of the code change, that is good for the times when users are not online and will come back after we deploy a new version while online users still are imminent to the issue we’ve proposed.

There is something else that we need to mention here and that is the mapper JavaScript file, in every production build there will be a mapper file called index.[hash].js or bundles.[hash].jsor something similar depending on the build tool that contains the mapping for all the separated chunks and bundles indicating that page 01 needs file xyz.[hash].js to be downloaded and executed or page 02 needs file abc.[hash].jsto be loaded on the browser so keep this in mind.

Now assume user Max is currently on page xyz.hash1.js and we have another page called abc.hash1.js .

Our build folder assets are like this:

📁 dist/
├── 📁 app/
├── 📄 index.html
├── 📄 abc.hash1.js
├── 📄 xyz.hash1.js
└── 📄 bundles.hash1.js

Then we deploy a new version, now all the hashes of all lazy-loaded routes or JavaScript static files are changed instantly so the xyz.hash1.js is now probably having a new name like xyz.hash2.js so the current page name is changed but since we’ve loaded it before the latest deployment, it is being served from the browser cache and there is no worrying about it crashing or breaking.

Cache mechanism in the browsers

In the above figure, I have shown you a simplified illustration of how browsers use a caching mechanism to cache files and assets, it follows the Hit and Miss paradigm which is controllable by the server headers like nginx or Apache servers, they have some specific headers to control how the caching behavior is handled.

Cache-Control headers:
no-store => Do NOT store any files in the cache.
no-cache => Do NOT attempt to check the cache (no Hit always Miss).
must-revalidate => Always check if the asset is fresh.
max-age=604800 => Maximum time that an asset is fresh in seconds.

If a web app is served for the first time and there is no “no-cache” header in the response headers of the server, the browser parses the HTML document checks the assets it needs for it, and tries to check the internal memory cache to see if a cached version is available based on the Filename if it misses the cache then it will try to request to the server for that specific asset and it will cache the asset, for example, an image or JavaScript or CSS file.

Next time when the user soft refreshes the page, the files will be served from the cache and not the server so it will be blazing faster. All of this caching is happening based on the Filename and that’s why we use content hash or hash codes when we build our static files in the production version of our React or other framework/libs to let the browser be aware of the changes and revalidate the files and this technique is called cache-busting.

Now let’s keep going with the main topic, the build folder is now like this:

📁 dist/
├── 📁 app/
├── 📄 index.html
├── 📄 abc.hash2.js
├── 📄 xyz.hash2.js
└── 📄 bundles.hash2.js

Now Max will try to open a new page that needs abc.hash1.js because, in our mapper that we’ve mentioned above, it says the new page needs abc.hash1.js to be downloaded. The browser checks the cache but it can not find the file so it tries to download and execute the file but it also can not find it on the server as well, now the moment of truth is here, the application crashes and shows an error indicating that the chunk load is failed! This is a very bad user experience and can destroy the reputation.

What are the solutions?

There are many ways to deal with this issue but I will go through some of the most important ones here.

1- Add these header meta elements to your index.html file:

<meta http-equiv=’cache-control’ content=”no-cache, no-store, must-revalidate”>
<meta http-equiv=’expires’ content=’0'>
<meta http-equiv=’pragma’ content=’no-cache’>

They are forcing the browser not to cache your HTML file and always get the latest version when users come back to your application.

2- If you are using nginx for your webserver you can use these directives to have more control over the caching part:

add_header Cache-Control “no-store, no-cache, must-revalidate”;

Or you can specifically tell what types of files you want to control and when they should expire:

   location ~* ^.+\.(css|js|png|jpg|jpeg|gif|ico)$ {
expires 5m;
add_header Cache-Control "public, immutable";
}

3- Retry policy on route lazy loading:

You can write a wrapper function on top of the React lazy built-in function to set up a retry policy depending on the needs, this part is the most important section that needs to be done to fix the issue automatically.

import { lazy } from "react";

type ComponentImportType = () => Promise<{ default: React.ComponentType }>;

const sessionKey = "lazyWithRetry";

const lazyWithRetry = (componentImport: ComponentImportType) => {
return lazy(async () => {
const hasRefreshed = globalThis.sessionStorage.getItem(sessionKey) || "false";

try {
globalThis.sessionStorage.setItem(sessionKey, "false");
return await componentImport();
} catch (error) {
if (hasRefreshed === "false") {
globalThis.sessionStorage.setItem(sessionKey, "true");
globalThis.location.reload();
}

if (hasRefreshed === "true") throw new Error("chunkLoadError");
}
return await componentImport();
});
};

export default lazyWithRetry;

With the above function, every time the chunk fails to load, it will attempt to hard refresh the page and get the latest version of all the assets including mapper function,index.html and other bundles instantly and the user will see the latest version as well.

Now you can replace lazy with lazyWithRetry to load dynamically imported files:

const lazyPageOld = lazy(() => import('./page'));

const LazyPageNew = lazyWithRetry(() => import('./page'));

4- Retry policy on component lazy loading:

If you are using component lazy loading inside routes that are lazy loaded as well, you may end up having chunk errors in those components as well as the pages and this will create an infinite loop in the above solution, here you will need a little modification to the above lazyWithRetry function to get the names and check the refresh attempt on each one of them to prevent the infinite loop.

import { lazy } from "react";

type ComponentImportType = () => Promise<{ default: React.ComponentType }>;

const sessionKey = "lazyWithRetry";

const lazyWithRetry = (componentImport: ComponentImportType, name:string) => {
return lazy(async () => {
const hasRefreshed = globalThis.sessionStorage.getItem(`${sessionKey}-${name}`) || "false";

try {
globalThis.sessionStorage.setItem(`${sessionKey}-${name}`, "false");
return await componentImport();
} catch (error) {
if (hasRefreshed === "false") {
globalThis.sessionStorage.setItem(`${sessionKey}-${name}`, "true");
globalThis.location.reload();
}

if (hasRefreshed === "true") throw new Error("chunkLoadError");
}
return await componentImport();
});
};

export default lazyWithRetry;

5- Bonus: Version-aware deployment with user notification (soon):

Now you might want to ask what if a user is already online and we deploy a new version instead of changing anything in existing files, there are new files added or some files removed? what will happen then?

In such scenarios, we need to inform the user that there is a new version and they should refresh the application to try to get the latest version or we do it for them. But it needs some extra work to be done.

Here you can read the caching strategies article:
https://maxtsh.medium.com/caching-strategies-for-front-end-developers-using-a-service-worker-6264d249f080

I hope this tutorial was helpful for you, if it was please follow me for further articles. thanks for reading, Happy Coding!

Useful Resources:

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control

https://ethang.dev/blog/cache-control-header-generator

https://www.codemzy.com/blog/how-to-name-webpack-chunk

https://www.codemzy.com/blog/how-to-name-webpack-chunk

https://raphael-leger.medium.com/react-webpack-chunkloaderror-loading-chunk-x-failed-ac385bd110e0

--

--

Max Shahdoost
Max Shahdoost

Written by Max Shahdoost

A highly passionate and motivated Frontend Engineer with a good taste for re-usability, security, and developer experience.

Responses (2)