Optimize performance with React.lazy and Suspense
Nikolas ⚡️
Posted on April 5, 2023
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.
Posted on April 5, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.