Lazy Loading
Have you ever heard of LaTeX?
It's a formatting system typically used to display math notation, written in the TeX programming language. If you have any mathy friends, you can impress them by knowing that it's pronounced "lah tek".
To render this notation, we'll need to use an NPM package. Here's an example using the react-latex-next
library:
Code Playground
The react-latex-next
package is essentially a React wrapper for LaTeX. Despite its name, it has nothing to do with Next.js (though it does work fine in Next).
I learned all about LaTeX when I worked for Khan Academy. One of their more popular open-source projects is KaTeX, a JavaScript-based LaTeX parser/renderer. It's used internally by most of the LaTeX NPM packages, including react-latex-next
.
One thing I discovered is that there's a lot of different math notation, and KaTeX can handle just about all of it. Including abominations like this one:
As a result, the react-latex-next
package is substantial: it'll add 72 gzipped kilobytes (opens in new tab) to your JS bundle, more than React + React DOM!
Now, if you take care to only use this package within Server Components, you don't have to worry about it, since this code will never be shipped to the client. But what if you need to be able to re-render on the client? For example, what if the user can edit the equation?
In cases like this, we'll want to leverage lazy loading.
Understanding lazy loading
The big idea with lazy loading is that we'll defer loading the JavaScript code associated with a particular component until it's needed.
This isn't a new idea. In fact, it's been a core part of the React API for a few years, via the React.lazy() (opens in new tab) method. You can use this in almost all React settings, including client-side-only frameworks like Parcel!
Next.js has their own wrapper around React.lazy()
, and we'll talk about that in a bit. For now, though, I think it'll be helpful if we start with React.lazy
.
Here's what it looks like:
// Instead of this:import Latex from 'react-latex-next';
// ...do this:const Latex = React.lazy(() => import('react-latex-next'));
Either way, Latex
will be a React component. The difference when we use React.lazy
is that it'll be lazy loaded.
This works using the dynamic import()
syntax. Like the standard import
statements we've been using throughout the course, dynamic imports are resolved by bundlers like Webpack when we compile our code. Specifically, dynamic imports signal to bundlers that it should split off a new bundle; react-latex-next
module, and any sub-dependencies, will not be included in the current bundle.
When I first saw this sort of thing, I had a lot of questions. It felt very magical to me. In this lesson, I'm going to share what I've learned, and hopefully answer a lot of the questions you might have!
Let's start by looking at a scenario:
'use client';import React from 'react';
const Latex = React.lazy(() => import('react-latex-next'));
function MathQuestion() { const [showMath, setShowMath] = React.useState(false);
return ( <> <button onClick={() => setShowMath(true)}> Reveal equation </button>
{showMath && <Latex>{'$2^4 - 4$'}</Latex>} </> );}
export default MathQuestion;
In this scenario, the showMath
state variable is used to conditionally render math notation. By default, showMath
is false
, and so we don't render our <Latex>
element. The user has to click the button to toggle it on.
The whole idea with lazy loading is that we defer loading the code until it's needed. Because we aren't rendering <Latex>
initially, we don't need to download the react-latex-next
module code.
As a result, when the user visits our app, this bundle won't immediately be downloaded. We just shaved off 72kb of JavaScript from the initial page load experience! As a result, the app will be hydrated and become interactive more quickly.
Of course, nothing in life is free. We still need to download those 72,000 bytes if we want to render some math notation!
When the user clicks on the button, showMath
flips to true
, and React will say “Oh shoot, I don't have this component!”. It makes a request to our server, to fetch the 72kb JS bundle. Once it's downloaded and parsed, React has the code it needs to finish the re-render.
Here's what that looks like, on a slow-ish internet connection:
It takes a couple of seconds to download the JS bundle. During that time, the user is given no indication that something is happening. 😬
Fortunately, we can solve this with Suspense. Here's what that looks like:
'use client';import React from 'react'
import Spinner from '@/components/Spinner';
const Latex = React.lazy(() => import('react-latex-next'));
function MathQuestion() { const [showMath, setShowMath] = React.useState(false);
return ( <> <button onClick={() => setShowMath(true)}> Reveal equation </button>
<React.Suspense fallback={<Spinner />}> {showMath && <Latex>{'$2^4 - 4$'}</Latex>} </React.Suspense> </> );}
This will render a spinner while the math-rendering code is downloaded and fetched:
Quite a bit better!
This works because React.lazy()
is a Suspense-compatible wrapper around the underlying component. When Latex
is rendered for the first time, React realizes that we don't actually have the component (the real Latex
component, the one defined in react-latex-next
), and so it does the “throw a promise” thing, to suspend this portion of the React tree.
When the data is received and everything's ready, React re-renders using the real Latex
component, and this slice of the application un-suspends.
Fundamentally, this is the same sort of client-side Suspense operation we saw with the Facebook Ads Manager example. Except we aren't waiting on a bunch of JSON data, we're waiting on our JavaScript bundles!
Lazy loading and Server Side Rendering
In the scenario above, our <Latex>
component was being conditionally-rendered, with an initial value of false
. But what if our component is there from the very first render?
For example, what if we were doing something like this?
'use client';import React from 'react'
const Latex = React.lazy(() => import('react-latex-next'));
function LatexEditor() { const [expression, setExpression] = React.useState("$2^4 - 4$");
return ( <> <label htmlFor="expression-input">Enter LaTeX</label> <input id="expression-input" type="text" value={expression} onChange={(event) => setExpression(event.target.value)} />
<h2>Output:</h2> <Latex>{expression}</Latex> </> );}
export default LatexEditor;
In this scenario, we've built a LaTeX editor. An expression
state variable is controlling a text input, and the value is being passed into our Latex
component to render the math notation.
The key difference is that the <Latex>
element is always there. It's not being conditionally rendered, to be toggled on at some point in the future.
This raises a couple of questions:
- Is there any benefit to doing this? Either way, won't the code need to be downloaded immediately?
- In an SSR context, the first render happens on the server. How does lazy loading interact with SSR?
Let's tackle that second question first: Lazy loading has no effect on the server. Our server-generated HTML will include the math notation, no matter whether we do a typical import, or a dynamic lazy load import.
This makes sense when we step back and consider the point of lazy loading. When a user visits our app for the first time, they need to download a boatload of JavaScript before the app is interactive, and we want to improve performance by reducing the number of kilobytes that need to be downloaded.
When we're generating the initial HTML on the server, however, we don't have this concern. The server already has all of the JavaScript code, ready to go. It doesn't have to download anything. And so we might as well use that code to generate the best initial HTML possible.
But what's the point? Why would we lazy load something which is used immediately?
Spend a few moments thinking about this. See if you can figure out why we might still want to do this.
This graph will explain the benefit:
Default (no lazy loading)
This is a data visualization which shows a sequence of events between client and server. Each event is represented here as a list item.
- "Render App" on server. Duration: 6 units of time.
- Response from server. Duration: 4 units of time.
- "Download JS" on client. Duration: 12 units of time.
- "Hydrate" on client. Duration: 8 units of time.
Regardless of whether we use lazy loading or not, we still need to download the same amount of JS, and we still have to spend as much time hydrating our components.
But by lazy loading the <Latex>
component, we break that work into two chunks. The math notation is deprioritized. All of the non-math parts of the app — the header, the footer, any other widgets and gadgets — will become responsive more quickly.
We can't avoid downloading those 72kb for our math notation, but we can push it back so that it doesn't block everything else from happening.
Lazy loading and Streaming SSR
I remember being very confused about the relationship between lazy loading and the “Streaming SSR” stuff we talked about recently. Do they both sorta do the same thing, in a different way? Is one better than the other? How do we know which one we should use??
While they both have a similar agenda — improving the initial load experience by breaking it up into chunks — they solve different problems. They generally can't be used interchangeably. They're separate tools that are used in different situations.
Streaming SSR is used when our HTML rendering is blocked by some asynchronous work. For example, in Sole&Ankle, we couldn't render the “shoe category” page until we had retrieved the list of shoes from the database. The server was blocked, and we unblocked it by giving it permission to send the HTML in chunks.
By contrast, lazy loading doesn't affect server-side rendering at all. Lazy loading is explicitly about how our client-side JavaScript is split into different bundles.
Here's how I think about it:
- We use Streaming SSR when the server is taking too long to generate the HTML.
- We use lazy loading when the client is taking too long to download the JavaScript.
Lazy loading is a bit like a less-aggressive version of React Server Components. With lazy loading, we send the JS later, but with Server Components, we don't send the JS at all.
“Suspense” is a low-level tool that is used in all of these situations to define boundaries around logical chunks of our application. Different higher-level tools use those boundaries to control what happens when.
I know that this stuff can feel really overwhelming. If I'm honest, I still find it hard to keep it all straight! But here's the good news: once you start practicing with this stuff, it becomes a lot clearer. And you'll have the chance to practice both of these ideas in the project, coming up soon!
In summary
In this lesson, we've seen how React approaches lazy loading. It's worth noting, though, that “lazy loading” is a much broader term.
For example, we can set native HTML <img>
tags to lazy load:
<img src="/animals/panda.jpg" alt="an adorable panda chewing on bamboo" loading="lazy"/>
This is typically used on images that are “below the fold”, outside the viewport when the page is scrolled to the top. The browser will defer loading the image until it's about to be scrolled into view.
This is the same fundamental idea that we've been talking about: don't load the thing until the user needs it!
This nice neat mental model is clouded by the fact that we can “lazy load” things immediately, like we saw in the Latex editor scenario. But ultimately, it's a twist on the same idea: we want to load the Latex model after everything else, because the other stuff needs to be loaded even more eagerly.
React.lazy()
is a handy tool to keep in our back pocket, especially when working with larger NPM packages. It's not something we use every day, since Next is already heavily optimized out of the box. But if you notice your JavaScript bundles starting to balloon, lazy loading is a valve we can twist to release some pressure.
You can learn more about React.lazy()
in the official docs (opens in new tab). You can also check out the full versions of the Latex stuff we talked about on Github (opens in new tab).