BlogReactJS

How to fix ChunkLoadError in your ReactJS application

Written by Codemzy on March 16th, 2022

Do you or your users get a ChunkLoadError after your deploy updates? If you use code splitting and dynamic imports, old code can cause this issue. Here's how to fix it in React.lazy() with a custom function.

Although this blog post is called "How to fix ChunkLoadError", like me, you probably ran into this error in production. And it's not always something you can fix, exactly. But what we will do, is let the browser try again and (hopefully) avoid the user seeing an error.

What are chunks?

If you're getting a ChunkLoadError, then you are code splitting. This is a feature in bundlers like Webpack, to break up your application into smaller Javascript files. And it's (usually) a better experience for your application users.

Code splitting is one of the most compelling features of webpack. This feature allows you to split your code into various bundles which can then be loaded on demand or in parallel. It can be used to achieve smaller bundles and control resource load prioritization which, if used correctly, can have a major impact on load time.

- Webpack Code Splitting

With code splitting, your code bundles are smaller so they take less time to load.

But I say it's usually a better experience, because if your run into the ChunkLoadError then one of those bundles doesn't load at all. At best, your user gets a nice error message, at worst your entire application crashes.

What can cause the chunk load error?

One of the main reasons for a ChunkLoadError, and the one I will focus on in this blog post, is that your chunk just doesn't exist anymore. And that can happen after every new deployment!

Let's say some of your chunks are dynamically named, like a number, or a contenthash for cache-busting in production. These names get created automatically by Webpack at build time.

Now imagine that your code is out in production, a user loads up your main app app.min.js. They are loving the experience (of course!), and now they want to dive deeper.

They click on a button to view their account settings. This is a separate chunk of code chunk-1a.min.js.

If there have been no changes since the user loaded the app, the chunk will load and they will carry on, blissfully enjoying your app.

But, what if between loading the app, and navigating to settings, you deployed a new version of the app. Maybe you just added a cool new feature so that users can add a profile picture.

And what if that had created a new chunk name for the account settings bundle chunk-1b.min.js. Chances are, your production environment will create a fresh build, that old chunk is gone, and the new one is ready for action.

But your user has already loaded the old version of your code, and it looks for the old chunk chunk-1a.min.js. Which is gone! ChunkLoadError!

How can I fix the chunk load error?

One option is to keep your old bundles available, but I don't really like that idea. You want your users to get the latest code and access those new features you've pushed!

And when you get that ChunkLoadError, it could be an indicator that the user's browser is trying to access old code. Because they have downloaded code that's now out of date, and it's looking for the old chunks.

A quick refresh by the user would fix this issue. But you can't expect them to know to do that. And we can do it for them (without them even knowing!).

And here's how you would do that in ReactJS.

If you are code splitting in ReactJS, you are probably using React.lazy, and the code below will work with that function. But if you're code splitting with dynamic imports, you can apply the same logic (and put it in a catch).

Going back to our example of a separate chunk for the user settings, maybe you import that code like this.

const UserSettings = React.lazy(() => import(/* webpackChunkName: "userSettings" */ './settings')));

At the moment, we give React.lazy a function that imports our code for the chunk. We're going to change this to wrap the import inside another function called lazyRetry.

const UserSettings = React.lazy(() => lazyRetry(() => import(/* webpackChunkName: "userSettings" */ './settings')));

This lazyRetry function will handle refreshing the browser in the event of an error.

const lazyRetry = function(componentImport) {
    return new Promise((resolve, reject) => {
        // TO DO
    });
};

The new function takes the component import function as an argument (that used to be passed to React.lazy) and returns a Promise. It returns a Promise because we are passing this to React.lazy, and that's what React.lazy requires.

React.lazy takes a function that must call a dynamic import(). This must return a Promise which resolves to a module with a default export containing a React component.

- ReactJS React.lazy

If you are not using React.lazy for code splitting, you might be able to skip the Promise bit from your function.

We need our new lazyRetry function to try to import the component and assuming everything goes well, resolve the Promise with the import.

const lazyRetry = function(componentImport) {
    return new Promise((resolve, reject) => {
        // try to import the component
        componentImport().then((component) => {
            resolve(component);
        }).catch((error) => {
            // TO DO
            reject(error); // there was an error
        });
    });
};

Great, now everything is working just as it was before. We've added a bunch of extra code for no reason! The catch method is where this new function will improve our error handling.

Since you can refresh the page with window.location.reload() you might be tempted to stick that in and be done with it. But an error could occur for other reasons and refreshing might not solve it.

You could check if error.name === ChunkLoadError and then do the refresh. But there are a few other reasons you might get a ChunkLoadError (network issues, firewalls etc), and if that's the case, it might happen again.

We don't want to keep refreshing the page for all of eternity. No one will enjoy that. We only want to do it once, to update the code to the latest version.

When we refresh the browser, the code will get reloaded, so it won't know if this error is happening for the first time or not. We need a way to tell it. So to do that, we can store the information in sessionStorage.

You could also store it in localStorage, but since we don't need the data to stick around once the window is closed, I chose to use sessionStorage.

So if there's an error, before we refresh the page, we can set a retry-lazy-refreshed key in sessionStorage to true.

window.sessionStorage.setItem('retry-lazy-refreshed', 'true');

This means that if we hit an error, your code can check if the page has already been refreshed.

const hasRefreshed = JSON.parse(
    window.sessionStorage.getItem('retry-lazy-refreshed') || 'false'
);

And to make sure that's not an old refresh, we will also set retry-lazy-refreshed to false when the module is successfully loaded.

Here's the final code:

// a function to retry loading a chunk to avoid chunk load error for out of date code
const lazyRetry = function(componentImport) {
    return new Promise((resolve, reject) => {
        // check if the window has already been refreshed
        const hasRefreshed = JSON.parse(
            window.sessionStorage.getItem('retry-lazy-refreshed') || 'false'
        );
        // try to import the component
        componentImport().then((component) => {
            window.sessionStorage.setItem('retry-lazy-refreshed', 'false'); // success so reset the refresh
            resolve(component);
        }).catch((error) => {
            if (!hasRefreshed) { // not been refreshed yet
                window.sessionStorage.setItem('retry-lazy-refreshed', 'true'); // we are now going to refresh
                return window.location.reload(); // refresh the page
            }
            reject(error); // Default error behaviour as already tried refresh
        });
    });
};

Multiple lazyRetry per route

The above code works fine if you split your code with route-based code splitting, which is what I tend to do.

But I did wonder what would happen if you had multiple React.lazy imports on a single route.

If you need to do multiple module imports on a single route/page and one is successful and the other isn't, the successful one could keep resetting the sessionStorage. It would create that endless refresh loop we talked about earlier.

In that situation, I would pass a value as a name to the lazyRetry function to identify the module.

const UserSettings = React.lazy(() => lazyRetry(() => import(/* webpackChunkName: "userSettings" */ './settings'), "userSettings"));

And I would include that name in the sessionStorage key so that the page only reloads once per import fail.

const lazyRetry = function(componentImport, name) {
    return new Promise((resolve, reject) => {
        // check if the window has already been refreshed
        const hasRefreshed = JSON.parse(
            window.sessionStorage.getItem(`retry-${name}-refreshed`) || 'false'
        );
        // ...

You only need to make these adjustments if you have multiple React.lazy imports per route. I have multiple bundles loading inside my React.lazy import, and the original lazyRetry handles any errors as intended, no matter which chunk fails to load.