In the modern web, code splitting is essential. You want to keep your app slim, with a small bundle size, while having “First Contentful Paint” as fast as possible. But what happens when a dynamic module fails to fetch? In this short article, we’ll see how to overcome such difficulties.

Code Splitting

An app that splits its code by routes might look something like this:

import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  </Router>
);

This means that navigating to /about will fetch the About component “on-demand”, and it won’t be part of the app’s main bundle, allowing the app to be thinner in bundle size.

You can read more about splitting your code in react’s official docs.

The Problem

What will happen if the file cannot download at the time of navigation? the app will crash, as React.lazy failed to import.

In cases of issues with the user’s Internet connection, this might be an actual problem. At my workplace, Logz.io, we are running an app with thousands of online users, and it’s common to see failures in module fetching.

You might have seen this error once:

Failed to fetch dynamically imported module: https://example.com/assets/Home.tsx

It might be caused by numerous network issues; slow connection, timeouts, WIFI hiccups, changing VPN, DNS issues, fragile hotspots — the list goes on.

Solution — the naive way

Now that we know modules can sometimes fail to download, we can try re-fetching it. Using React.lazy function definition, we can create a wrapper, and re-execute the importer over and over.

const lazyReactNaiveRetry: typeof React.lazy = (importer) => {
  const retryImport = async () => {
    try {
      return await importer();
    } catch (error) {
      // retry 5 times with 1 second delay
      for (let i = 0; i < 5; i++) {
        await new Promise((resolve) => setTimeout(resolve, 1000w));
        try {
          return await importer();
        } catch (e) {
          console.log("retrying import");
        }
      }
      throw error;
    }
  };
  return React.lazy(retryImport);
};

This LazyReactNaiveRetry has the same signature as React.lazy. It just wraps the importer with a simple retry mechanism.

The usage will look something like this:

const LazyAbout = LazyReactNaiveRetry(() => import("./components/About"));
const LazyHome = LazyReactNaiveRetry(() => import("./components/Home"));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<LazyHome />} />
        <Route path="/about" element={<LazyAbout />} />
      </Routes>
    </Suspense>
  </Router>
);

The above approach won’t work. When the browser is fetching a file, no matter what’s coming back, it will cache the response, even if it’s a failed response. Thus, each time we call import again, it won’t initiate a new network call, and will immediately return the cached response, and in case of failure, a failed response.

Solution — refined

Let’s try again. We need some way to tell the browser that we want to retry fetching the module. We can do it using a mechanism called cache-busting. We can add a dynamic search parameter for each network call. It will force the browser to fetch the module again, without harming the actual content.

  1. https://example.com/assets/Home.tsx
  2. https://example.com/assets/Home.tsx?t=1671503807894
  3. https://example.com/assets/Home.tsx?t=1671503829688
export const lazyWithRetries: typeof React.lazy = (importer) => {
  const retryImport = async () => {
    try {
      return await importer();
    } catch (error: any) {
      // retry 5 times with 2 second delay and backoff factor of 2 (2, 4, 8, 16, 32 seconds)
      for (let i = 0; i < 5; i++) {
        await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** i));
        // this assumes that the exception will contain this specific text with the url of the module
        // if not, the url will not be able to parse and we'll get an error on that
        // eg. "Failed to fetch dynamically imported module: https://example.com/assets/Home.tsx"
        const url = new URL(
          error.message
            .replace("Failed to fetch dynamically imported module: ", "")
            .trim()
        );
        // add a timestamp to the url to force a reload the module (and not use the cached version - cache busting)
        url.searchParams.set("t", `${+new Date()}`);

        try {
          return await import(url.href);
        } catch (e) {
          console.log("retrying import");
        }
      }
      throw error;
    }
  };
  return React.lazy(retryImport);
};

While the usage remains the same:

LazyReact is now wrapped with a working retry mechanism.

Voilà! We now have a working proof-of-concept of retrying a failed dynamic module fetching.

Demonstration

Here we are temporarily blocking the request in Chrome using the Chrome -dev tools. After several failures, we stop blocking and allowing the module to be loaded (status 200). Emulating a real network issue on the users’ side.

Real-life data

A revision of this code is deployed to Logz.io. Since this has been deployed, we are seeing a decrease of 94% in failures to fetch dynamic modules with the expected error: Failed to fetch dynamically imported module

References

It turns out that this is a known issue and is being discussed in the community for some time.

Full example repository

I’ve created a Github repo with the full code working.

Note

This mechanism can be used not only in react, but on every framework. as dynamic import is a javascript feature. The trick here is cache-busting.

EDIT: 22–02–2023

The above solution support only one chain of the file import tree. meaning, that if we have this import relation parent.js => child.js the retry can only succeed on the first file parent.js . The reason is that the cache-busting mechanism would only affect the first import URL. but the second file would not be re-fetched. That is why it’s important to solve this issue from the root, internally by the browser mechanism, as mentioned in the references section above.

Get started for free

Completely free for 14 days, no strings attached.