If you’ve ever worked with a mid-to-large Web application, you’ve probably noticed that bundle size (the amount of JavaScript code sent to the browser) is a growing monster. With all the added libraries or administration-only functionality, your application starts to load a little slower for the average user. While it may still feel high performance on your laptop, people with poor reception places last year’s phone models want to quickly feel performance degradation.
It turns out that the main problem isn’t really the total amount of code you download, but the amount of code you have to download (parse and run) before you can show the user anything of interest. For some users, this delay can be really long and will certainly translate into lost dollars for your business. In other words – it’s a challenge worth investing in.
The purpose of this article is to give you a great introduction to lazy loading as a concept, how it is done in response, what methods are used and when you will get the best results possible.
What is lazy loading?
Lazy loading is a technique that requires code to be retrieved only when it is needed. You split a large JavaScript package into a bunch of different parts, a technique called code splitting, which can include the code for pages, parts, or even individual components. When you need that code, you can ask the server for that particular code, then download it and load it into memory – just as if it had been there all along.
Breaking your application into several parts brings its own set of challenges. Determining what to put into each bundle, when to download it, and what to do while waiting for the code to download are all the challenges we need to solve.
The basics of lazy loading in React
Because this kind of code splitting is so important, React comes with built-in support for processing. Let’s start with a small example:
import { Router, Route } from 'react-router-dom';
import LandingPage from './pages/landing-page';
const AdminPage = React.lazy(() => import('./pages/admin-page'));
export const App = () => {
return (
<React.Suspense fallback={<Spinner />}>
<Router>
<Route path="/" component={LandingPage} />
<Route path="/admin" component={AdminPage} />
</Router>
</React.Suspense>
);
}
Copy the code
We are creating an application with two routes -/ and /admin. By default, we bundle the login page with the application’s main bundle (and load it as usual). However, in the admin page, we are doing something different.
The line react.lazy (() => import(‘./pages/admin-page’)) may be a new syntax for many people – but it’s been built into JavaScript for years. Let’s take a quick look at what’s happening and what it means for the user experience.
The import(‘./some/file’) syntax is called dynamic imports, and they work by asking to bundle specific parts of the software. They are often used as tags in bundling tools such as Webpack or Parcel, and make these tools output more JavaScript files with less code per file.
The react.lazy () function basically tells React to “pause” its rendering while we wait for this particular part of the application to load from the server and be parsed. The concept of hanging is quite new, but for now you need to know that the component that wraps our application will display a back-up UI whenever we wait for code to download.
In other words – the process is as follows:
To explore the demo
This sounds reasonable, but let’s look at a demo to see it in action.
In CodeSandbox, we are lazily loading components that use some very large libraries. Since we only use these libraries in the problematic components, we can delay loading them until we wish to display them on the screen.
When rendering “redundant” components, we wrap them in < react.suspense /> boundaries. This means that we can display a backup UI (such as a spinner, frame UI, or just a text string) while waiting for the component code to download.
In Chrome development tools, this process looks like this:
Name binding, load order adjustment
The bundled software we loaded worked fine, but they weren’t properly named. .chunk.js and 1.chunk.js may do a good job, but it is not very suitable for debugging errors in production.
Fortunately, there is a way to improve block naming. Webpack supports a special annotation syntax called “magic annotations” that lets you name different blocks.
First, let’s go to the app.tsx file where we specify dynamic imports. We need to change this:
() => import("./SomeExpensiveComponent")
);
Copy the code
To this:
() =>
import(
/* webpackChunkName: "SomeExpensiveComponent" */ "./SomeExpensiveComponent"
)
);
Copy the code
Now we can reload the page and notice that the block name has changed!
Code splitting and loading strategies
Code-splitting and lazy loading code is great for loading only the parts of the application that the user is currently using. But where do you set thresholds for what should be bundled together and what should be lazily loaded?
There are two main approaches, both of which have advantages and disadvantages.
Routing based split
Many developers first split their applications based on routes so that each route (or screen in the application) has its own bundle. This is a good approach for situations where you have many routes but users rarely access all of them. The concept also makes sense – why download code for a page the user hasn’t already visited?
The disadvantage of splitting applications based on routing is that you lose the immediacy of browsing a single page application. In addition to browsing immediately, we need to download the new page in the background before it is displayed to the user. There are a number of techniques available to mitigate this (for example, preloading the page every time the user hovers over the link), but it is difficult to do this correctly.
Component-based splitting
Some developers decide to split most of the major components into their own bundles. Since the routes themselves are bundled together, most challenges can be solved with a route-based approach.
However, there are drawbacks to this approach. Every time we lazily load a component, we have to provide a back-up UI when it loads in the background. Also, each block comes with a little overhead and network latency, and they add up to an even slower experience than doing nothing at all.
All Things Split Based My experience is that I get the best results by combining the two approaches and considering which is the best value for money in any given situation.
A good example is the idea of dividing your application into “logged in applications” and “logged out applications” (provided by Kent C. Dodds’ article on authentication in React). Here, you’re making a high-level division between two parts of the application that rarely overlap.
Another example is code that isolates little-used routes, such as contact pages or privacy policies. In this case, displaying the spinner box for a second may be perfectly acceptable, as it is not part of the primary user experience.
The third example is when you have to load a particularly large component – such as a date picker library for when a user must enter his or her birthday in the registration process. Since most users don’t need it – we just show an alternate user interface, and we lazily load extra code for those who do.
Consider each case and measure the difference it provides in bytes. Many build systems, such as create-react-app, give you package sizes and individual block sizes that are different from the last build. Don’t be afraid to try – but make sure the difference in performance is worth the complex tradeoff.
Conclusion language
For most modern Web applications, adding some lazy loading is probably a good thing. You deliver less code to the end user only when you need it. Getting started is easy, and it can be easily extended to an entire application as long as you consider which strategy to use for a particular use case.