skip to content
Nikolas Barwicki - Javascript Blog Nikolas's Blog

Optimize performance with React.lazy and Suspense

/ 5 min read

Why would you need code-splitting

Typical React app has its files “bundled” into a single file. Tool like Webpack follows imported files and merges them into a “bundle”. The bigger the bundled file, the longer it takes for your app to load. Frontend developers should be extremely aware of this fact - especially when including large third-party libraries.

When user starts to use your app he probably doesn’t need all pages and components at once. Moreover, there is some code that the user may never need. Now, imagine that we can control which part of code are loaded so we can dramatically improve the performance of our apps.

To better understand which parts of the bundle is used by the user you can use the “Coverage” tool from the Chrome Dev Tools.

Real world use-case

The most common use-case for this in React is lazily loading route elements. Using this technique, pages that are not required on the home page can be split out into separate bundles, thereby decreasing load time on the initial page and improving performance.

Imagine that you have an app with multiple routes (pages) and they are loaded at once with one large bundle. The user experience isn’t that great - user has to wait for all pages to be loaded even if he only wants to see the Dashboard or just one another route. With lazy loading we can at first load the Dashboard page and then load another pages on-demand. That dramatically decreases the time of loading the first screen of our application.

Application setup

For our example application we will have Home screen on / path and About screen on /about path. I will be using create-react-app with react-router-dom@6. I’ve created two simple components/pages named Home and About exported as default exports.

Important! 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.

I also added Layout component with simple navigation:

function Layout() {
  return (
    <div>
      <nav>
        <ul>
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/about">About</Link>
          </li>
        </ul>
      </nav>
      <hr />
      <Outlet />
    </div>
  )
}

And App component looks has the following content:

import Home from './pages/Home'
import About from './pages/About'
import Layout from './components/Layout'

function App() {
  return (
    <Routes>
      <Route path="/" element={<Layout />}>
        <Route index element={<Home />} />
        <Route path="about" element={<About />} />
      </Route>
    </Routes>
  )
}

With that setup we have single, large bundle and all pages are loaded during the initial load. For this particular case it won’t be a problem - About component has only two elements with plain text and it doesn’t use any sizeable libraries. But we assume that our app is taking too long to load and we want to fix that as soon as possible.

According to our plan we want to have Home at the initial load and then load About on-demand.

React.lazy

Let’s start with changing the way how we import the About component:

// import About from "./pages/About"; ❌
const About = React.lazy(() => import("./pages/About"));

We’ve used React’s build-in support for loading modules as React components. When we run the app we would see the error. This is caused by the fact that while the component is lazily loaded React can’t display anything in its place.

Adding Suspense

In React we can fix this issue using <Suspense /> component. Using <Suspense /> allows us to render a fallback value while the user waits for the module to be loaded. Just wrap About with suspense and provide the fallback value. In place of Loading... screen you can pass any valid React component such as loading spinner.

<Route
  path="about"
  element={
    <React.Suspense fallback={<>Loading...</>}>
      <About />
    </React.Suspense>
  }
/>

With the following fix user should see Loading... in the place where the About component should be displayed while loading the module.

Comparison

Now let’s check how our changes affected the overall user experience. For demonstration purposed I’ve installed lodash imported it in the About component. I’ve set network throttling to Slow 3G.

At first let’s take a look at loading time and bundle size for the non-optimized app (no React.lazy and Suspense).

As you can clearly see we have a single bundle.js file which includes all files for the app. User had to wait almost 12 seconds to use the app, at least he didn’t had to wait to see the About page. However if he wanted to see just the home page he lost few seconds waiting for the whole bundle. There were over 470 kB transferred at once.

Now let’s perform similar test but using React.lazy.

Here you can see the results after navigating to the /about path. Initially there were 375 kB to transfer which took 2 seconds less than in the previous case. Used had to wait less to interact with the app. Unused lodash package wasn’t transferred at all.

After clicking the link to /about another scripts were loaded: lodash and chuncked file with About component. The size of transferred JS files is the same in both cases but the initial load time is shorter for the lazily loaded content.

Summary

In larger, production applications those differences between using lazy loading and not can be much greater. Here we had only simple components with almost no content. However the difference is noticeable even in this situation.

You can optimize the imports even further to improve the user experience - for example you can conditionally lazy load components on mouse hover or on app’s state change.