Chapter 9: Lazy Loading in React and Next.js Apps
There's a saying that all innovation was driven by human laziness, which actually makes sense. How is it connected to lazy loading? I don't know. 🤷
Table of Contents
Hey Frontend Ninjas,
In this chapter you are going to acquire one of the secret, ultimate techniques of performance grandmasters used to divide and conquer (pun definitely intended) slow loading websites since the dawn of the web era.
But before we start the training, a word of warning: lazy loading isn't for the faint of heart! It's an incredibly powerful technique for improving network performance and enhancing the user experience but it can also mess up your React application’s loading behavior if not done right. That’s exactly why I will teach you what lazy loading is, what are its effects, its typical use cases, how to identify where it’s best applied, where it’s best avoided and of course how to implement it perfectly with plain React and the tools coming with Next.js. Let’s jump right into it!
Lazy Loading: What is it And How it Works
Lazy loading is a technique that allows you to defer the loading of resources, such as images, scripts or React components until they're actually needed. This concept is based on the idea of "just-in-time" delivery of content: instead of loading all resources upfront, you only load what's necessary for the moment, and fetch additional resources as required for user interactions. The result is a faster loading time and more efficient use of network resources. An ideal outcome! 🚀 Combined with code splitting, this will become one of the most frequently used tools in your performance tuning toolbox! No wonder, it helps to temporarily increase how much your application resembles the ideal website. (Details are coming in Chapter 3: Learnings From The Fastest Website of The Universe)
The DPS of Lazy Loading
So that’s lazy loading in a nutshell, but why is it so useful? Let’s take a look at some real-life results:
By implementing lazy loading ClearTax improved their initial load time ~50%.
Quran.com shaved off 50kb from their entry chunk by lazy loading non-critical components.
Max Rozen halved the TTI on one of his previous projects by moving 200kb of code from the main bundle to a lazy loaded chunk.
Those are some massive gains! As we discussed in the introduction chapter, these could translate to serious financial gains on the right projects. The potential is tremendous so let’s get to the implementation details!
Implementing Lazy Loading in React
Ever since v.16.6, React has a built-in solution to execute this attack. A bit later I will also show you the legacy solutions and why some of them might still be relevant today. With this simple approach implementing lazy loading is not difficult at all.
You can lazy load components using
React.lazy()
.
This function takes another function as its argument, which should return a dynamic import()
statement for the module you wish to lazy load. Here’s a minimal example:
import React, { lazy, Suspense } from 'react';
const SneakAttack = lazy(() => import('./DelayedComponent'));
function LightningFastApp() {
return (
<div>
<Suspense fallback={<div>Camouflage...</div>}>
<SneakAttack />
</Suspense>
</div>
);
}
export default LightningFastApp;
Let’s break down what’s happening here.
We are importing the basic moves from ‘react‘ to build up the combo.
We define the custom component (
DelayedComponent
) we want to load in the background, escaping the line of sight of the users. (Well, not exactly in this example, but in real life situations)We create a simple but lightning fast top-level component to house the
SneakAttack
we are about to deliver.We wrap the
SneakAttack
in camouflage while we are preparing the ambush. Meaning, we simply provide fallback content to be displayed while the loading part of lazy loading is in progress.We tell React to deliver our
SneakAttack
by rendering it as a component. That will initiate the fetching of theDelayedComponent
and replace the fallback with it once arrived over the network or from cache.
You might have noticed that this example really doesn’t make much sense. All it does is delaying the rendering of DelayedComponent
by one render cycle (+ loading time). So this way it likely just worsens performance. You can check out a more meaningful implementation in the official docs where the loading only happens after a user actually tries to access a feature. However, even this version is good enough to discuss some important tips!
💡 Pro Tips (lots of them…)
Always Use Suspense: Suspense works with
React.lazy
by default, offering you an easy way to provide some content to the users while the resource requested is in transition. Choosing the right placeholder is an art in and of itself, I will share some tips about it down below.Pitfall With The Target of Importing:
React.lazy -
out of the box - only works with the default export of a module. If you want to lazy load a component exported by name, there’s a workaround: re export it from another file as the default export. (Read the old docs for an example). Alternatively you can use this approach:const LazyNamedComponent = React.lazy(() => import('./Named.js').then(module => ({ default: module.Named })) );
Or with a bit more up to date syntax:
const LazyNamedComponent = React.lazy(async () => await (import('./Named.js')).Named);
Pitfall With The Place of Importing: Make sure to import the lazy loaded component outside of any component’s code at the top level of the current module (file). If you move it inside one, a rerender can re-run the import and you will lose the current state of the already mounted lazy-loaded component whenever that happens.
Pitfall With Mixing Import Types: If you happen to load a component with the regular (non-dynamic) import syntax as well as dynamically, the latter will be disregarded and the code will be packed into the main bundle. Keep this in mind to avoid unpleasant surprises on the network tab! 🙈
Pitfall With Import Path: You can not use variables or string templates in the import path. It has to be a direct import with a simple string. You will read about a possible workaround just a bit further down in this list in SSR before React 18.
Pitfall With Tree Shaking: As dynamic imports can’t be analyzed at build time, to help bundlers with tree-shaking unused code we need to follow the official workaround and reexport our named components as a default export from a standalone module. This way bundlers can weed them out when necessary.
Pitfall With Deep Linking: (Specifically links with an id at the end eg: www.fast.site
/#speed
.) I can tell you from first hand experience, if you want to have deep links and lazy loading at the same time… you don’t get to eat your cake and have it too. At least the browsers are not going to scroll to your content marked with the id in the URL as it’s initially not in the DOM and they don’t care that it appears later. There’s a workaround though! If you want to emulate that behavior just check the URL with JavaScript once the component is loaded and trigger the scroll programmatically when it matches.Timing Concerns: Just as much as lazy loading can improve the user experience by enabling a faster initial load it can also destroy it if you don’t time the starting right. Ensure that loading is triggered as soon as necessary to minimize or completely eliminate the visible loading of the lazily loaded content. I mean, who likes those web shops with infinite loading product lists. slowly crawling for the next batch whenever you hit the bottom of the screen? I don’t for sure.
Avoid Layout Shifts: CLS is a metric in Web Vitals, meaning besides impacting user experience it also impacts search engine ranking so it’s crucial to minimize it. When there’s something on the initial view of a page (not just above the fold!) that you are lazy loading, you face the challenge of having to properly size the placeholder content so that it matches the size of what will be displayed later. It’s necessary to avoid janky shifting of the layout (more on this at web.dev) leading to a higher CLS value.
Use Skeletons Instead of Spinners: The current best practice is to use low resolution / low detail mock content as placeholders. The next minor challenge with this approach for the performance maximalists is finding the right balance between detail and payload.
Avoiding Fallbacks With The
startTransition
API: Sometimes it makes sense to prevent displaying fallback content while the lazy loading is in progress. A good example is a tab switcher with lazy loaded tabs. It’s a better user experience to see the old tab and have it interactive until the new one is ready than watching a flash of the fallback placeholder whenever the user clicks on another tab. Check the official method documented in the old React docs (nope, I couldn’t find it in the new) how to pull it off with the help ofstartTransition
.SSR and React 18:
React.lazy
and SSR are not used to play nicely together before v18. But that’s now in the past. You can read more about the details in one of React’s GitHub discussions on architecture. In a nutshelllazy
now enables you to segment the UI into separately renderable parts so you can stream some high priority content sooner. This naturally means first class support for lazy loaded components on the server.SSR Before React 18: In the age of old (or current projects pre-upgrade) we used to use either Loadable Component’s SSR support (intro here) or React Loadable’s (read more here). It’s worth noting that React Loadable is not in active development anymore but some projects still use it just all right (even with the officially incompatible Bable & Webpack versions) . Loadable Components is the de-facto successor and it has other merits (docs a bit outdated about SSR) that might warrant adopting it even with React 18 including the option for truly dynamic importing as you can use variables or string templates to define the import path with it.
Route Based Lazy Loading: With
React.lazy
you can lazy load anything not just routes. However you might actually want to lazy load routes in a plain React app. React Router supports that use case out of the box with thelazy
prop and dynamic imports. Read the details here.
Lazy Loading Is The Second Step in a Combo Move: It Starts With Code Splitting
We jumped straight to the finishing move but it’s true, in order to execute the lazy loading attack you first need to prepare something that you will load lazily.
How can we do that? That’s where code splitting joins the battle! In the world of native ES module support in-browser you could simply write and host a module yourself… but come on, that’s so 1990s. Today, virtually everyone works with a bundler like Webpack, Vite or Turbopack. Those tools will take care of splitting your modules according to what you want to lazy load. Webpack for example uses the presence of a dynamic import as the indicator to where to split the application to multiple files. In the end it will output pieces of JS code called “chunks“ that your app will be able to download and use separately.
💡 Pro Tips
Keep an Eye on The Size: Next.js will automatically display the size of all the output chunks at build time which makes it easy to manually check how we are doing. For monitoring and enforcing performance budgets on assets sizes you can read a comprehensive guide here. (Later on I will also cover this topic in detail.)
Without Next: Vite can do that same reporting for you by default. In Webpack land there are tons of useful plugins for this same purpose. Although a bit old, this is still a very cool article describing how to set them up for some serious performance wizardry.
What to Lazy Load and When?
Now that we’ve learned a few things about what lazy loading is and how to implement it in our React apps let’s focus on our enemies for a bit. You need to know about their weaknesses in order to choose a super effective attack against them! This section will include strategies and generic advice on when lazy loading can best help in improving performance.
Weakness #1: Lots of Images or Other Media
Imagine you're building an e-commerce website that showcases a wide range of products. Each product has multiple high-resolution images, making the site extremely image-heavy. Loading all these images upfront would lead to slow load times and a poor user experience.
By implementing lazy loading for images, you can ensure that only the images visible on the user's screen are loaded, while the rest are fetched on-demand as the user scrolls or clicks around. This saves bandwidth for the users and significantly improves the initial load time and overall performance of your website. And nothing restricts us to only images, we can deal with videos or even audio in the same way.
💡 Pro Tips
Progressive Image Loading: One of the best ways to deliver a good user experience while lazy loading images is to inline a very low resolution version of the image, render it blurred, initiate the fetching of the original and replace it with the full image once it’s loaded. Using a placeholder with the correct aspect ratios also helps with preventing a CLS increase. We will cover this technique in much more detail in Chapter 11: The Snappiest Static Assets on The Planet. In the meanwhile you can take a look at how Medium implemented this pattern or check out some of the most popular tools for creating those placeholders: LQIP and SQIP (used by yours truly on fullcontextdevelopment.com/blog).
Modern Formats: Another thing you can do to improve either the lazy or normal loading speed of images is using modern image formats like WebP or AVIF. These formats provide better compression and quality compared to traditional formats like JPEG or PNG.
CDN: Consider using an image focused CDN that can help you optimize and serve those visual assets as fast as possible. (Looking for a sponsor here 🥺)
Weakness #2: Huge Bundle Sizes
For large-scale applications with numerous components and features loading everything at once can be resource-intensive and slow. Many SPAs fall victim to the growing main bundle size syndrome, when slowly but surely the production build size spirals out of control.
A typical example is the good-old admin dashboard with lots of charts and diagrams, but any client-side-logic-heavy application can easily start to develop the symptoms. To resolve it you can lazy-load individual widgets or even full views of the app so they're only fetched when the user interacts with them.
💡 Pro Tips
CRP Optimization: One of the most frequently used techniques to help with this problem is called Critical (Render) Path Optimization. In a nutshell, it’s about packing above-the-fold content into a separate bundle/chunk than the rest of the page so the users first download only the “critical“ or minimal part needed to interact with the site meaningfully. We will spend a whole chapter on this topic in: Chapter 7 Implementing Critical Render Path Optimization With React and Next.js. That’s how important this technique is!
Other Assets: You can read a really nice generic introduction to lazy loading on MDN that also discusses how to use this technique with
fonts
,images
and eveniframes
. It mentions techniques for reducing the render blocking time of CSS and introduces one of my favorite tools, theIntersection Observer API
too.Use The Intersection Observer API: It’s a very effective weapon widely used on the web for killing slow page load times! The basic idea is to only initiate fetching a component’s chunk once the observer tells us it would become visible on the screen. It’s even better if we time it just a bit earlier than that so it will already be there once the users scroll to its position. I’ve used this technique a lot in my own projects and I plan to show some as examples later but for now I will link you to a nice tutorial about its implementation combined with
React.lazy
.
Weakness #3: 3rd Party Scripts
Very common performance hogs that lazy loading can effectively beat are the 3rd party scripts. They are often loaded immediately as the HTML is just being rendered for the first time. They sometimes even get bundled into the prod build of the application! 🤯 But in reality, they are often not needed immediately for the site to function as intended. It opens up the opportunity to lazy load them.
Good examples are 3rd party chat or comment widgets, captcha services or the code of any external services. In some cases even analytics or cookie consent code can be deferred! Eliminating these from the initial render of the app can often significantly improve the performance characteristics of a site. A real life example shared by MediaVine is how they improved their initial ad load time by 200% with lazy loading ads!
💡 Pro Tips
Lazy Loading Scripts: The tools of the trade here are the HTML attributes called
async
anddefer
of thescript
tag and therel
attribute of thelink
tag with thepreconnect
anddns-prefetch
values. Here’s an awesome article on web.dev showing exactly how to use them for some serious speed improvements. Key takeaway from there: useasync
for highest priority scripts anddefer
for less critical dependencies. But there are some exceptions, check the article.Lazy Loading Anything: You can lazy load just about anything that an ES module can hold. It naturally includes npm packages too. You can load them at any place or time simply by
(await import(‘name‘)).default
-ing them. The Next.js docs have a nice example of how to put it to good use. In the next section I will also share some strategies about how to utilize this for the best possible speed improvements.
Going forward, whenever a wild slow website appears with any of these weaknesses, you will know how to deal a super effective blow to them! In my experience this is really valuable knowledge so you already acquired some good skills! Let’s continue!
Identifying The Target 🕵️
Now that you can easily recognize the general patterns where lazy loading can help, we can also look at the generic approach to find good targets for this technique. Here’s what you should be looking for:
Components that aren't immediately needed on the initial view. Good examples would be modals, heavy dropdown menus or any element of the UI that appears on interaction.
Components that have a significant impact on loading time. As usual, doing a performance profile and analyzing the network/render waterfall is the best starting point. Some typical offenders you might find are large data tables, huge charts or not-always-used npm packages (like date handling… moment.js anyone?).
Components that are always conditionally rendered.
Components that are only present in certain routes of the app yet are put into the main bundle somehow. (mistakes happen)
Infrequently used features are also great targets for lazy loading. You can improve the UX for the majority of the users if you lazy load the least used parts of the app. The best way to identify them is going empirical. Use analytics or monitoring/logging data to see the interaction patterns of your site and look for those less frequently used features. ☢️ Make wise judgements. The least frequently used parts can still have critical importance.
Imports that are only needed when/after the user starts an interaction. If there are imports in a module that fit the description, you can either defer their loading until the user’s first attempt to use them or manually prefetch them even sooner so they wouldn’t have to wait.
👉 Infinite scroll is also a kind of lazy loading pattern but I’m sure you will know when you need it without any special speed improvement goals.
🧠 Performance Mindset
I found it really helpful in developing a performance mindset to pick up the habit of considering what to lazy load in the design phase of work. Be on the lookout for the above cases and you might avoid the need to “optimize performance” in the future, instead you could “simply“ keep it high all along. That’s the best thing you can do for your users and company profits too!
Lazy Loading With Next.js
You have already learned a ton about lazy loading in general and in combination with React. Now let’s go deeper and take a look at the unique tools that Next.js offers for implementing this technique!
Next takes the DX of lazy loading to a whole new level with its automatic code splitting and dynamic imports. By default, Next.js will split your codebase at the page level, ensuring that users only download the code required for the current URL. That’s a nice little favor from the framework authors, saving us all some time and effort.
👉 Now with React Server Components (RSC) it can automatically save you from including JS in the output of SSR when the code is only used for rendering but not needed for client-side interactivity.
To further optimize your Next.js application, you can use dynamic
from next/dynamic to lazy-load individual components. It gives us the same level of control over loading behavior as React.lazy
and a bit more!
Here's a cookie cutter, basic example of how to use it:
import dynamic from 'next/dynamic';
const LazyLoadedByNextJS = dynamic(() => import('./MyComponent'), {
loading: () => <div>Loading placeholder</div>,
ssr: false,
});
function App() {
return (
<div className="App">
<LazyLoadedComponent />
</div>
);
}
export default App;
It kind of follows the same pattern as the “native“ React solution. There’s not much difference between these two now in the v18 era as dynamic
uses Suspense
and React.lazy
internally. Its only significant, added feature is that you can prevent the lazy loaded component from rendering in SSG and SSR mode by passing the ssr: false
config option to the dynamic
call. Otherwise the component would be loaded and rendered in those situations too. (Of course only if the UI logic is set up that way.)
When do you need that ssr: false? Most frequently, in case of rendering stuff that:
Relies on browser-only APIs, like
WebGL
or theDOM
.Displaying real-time data like a chat widget or stock price chart where you might not want to show outdated information on the initial load.
💡 Next.js Pro Tips
App and Pages: There’s no need to worry,
next/dynamic
works the same way in both folders.Loading Placeholder: As you saw in the example, you can provide the placeholder component as a config option to the call.
Importing Default vs Named Exports:
next/dynamic
has the same issue with named exports as React.lazy (no wonder considering it’s using that.) As a work around you can do this:const NamedLazyLoadedComponent = dynamic(() => import('./NamedComponent').then((module) => module.Named) )
Client and Server Components: Server components are code split to their own chunks by default and can be streaming-rendered. Lazy loading is only relevant when working with client components. If you try to lazy load a server component only it’s child client components will be lazy loaded (if there’s any).
The Place of Calling Dynamic: Calling dynamic has to be in the top level scope of the module (file) in order to work. It means you can’t write it inside functions or React components.
That’s how you do generic lazy loading with Next.js but it has some additional tools that can be used to implement this method in specialized cases.
More Tools in Next’s Arsenal With Lazy Loading Powers
Next/Image: The developers of Next.js have upgraded the img
tag with special capabilities and made it available through the next/image component. Some of those has lots to do with lazy loading. They designed it in a way to only load images when they enter the viewport. You can optionally provide a blurred placeholder with the blurDataURL prop or if you are directly importing an image with a supported format the blurred version will be auto generated for you at build time when you use the placeholder prop.
Another neat, performance aware feature is the many built-in ways the component takes care of CLS as it will always reserve the right size for the image, even before it’s loaded. In addition, you can “mark“ an image as priority. This is best used for the LCP (largest contentful paint) image on each page so that it will be prioritized for the earliest possible load. Make sure to utilize this component to the max. It’s a very powerful and useful optimization tool that can do much more than what’s related to lazy loading!
Next/Font: Well, this one is not directly related to lazy loading fonts, just to loading them, but I might as well mention it here. next/font allows you to control when and where the different font files are loaded depending on where you use it. It also depends on whether you are rocking the pages
or the app
folder.
In the case of pages
, fonts requested with next/font
will be preloaded on that single route and fonts requested in the custom app will be preloaded globally.
Working within the app
directory you have more options. Requesting a font inside a page works the same way, however you can also request them in a layout that will preload the font for every route wrapped by it and non-surprisingly if you request one in the root layout it will be preloaded globally.
The nice thing about next/font
is that it does a lot more to ensure your app won’t suffer from any related performance issues. Most notably it can load fonts while ensuring zero layout shifts by using the size-adjust
CSS property. A must use if you are dealing with fonts in a Next.js application.
Next/Script: You can use next/script to control the timing of load and the scope of 3rd party scripts. Again, it can do more to optimize app performance than what’s related to lazy loading so make sure to check out the docs if you are interested. (I will cover those areas in other chapters later.)
What’s relevant here are the different loading strategies it offers, which makes it a very clean and easy to use tool for precisely controlling script loading. The most relevant strategy is called lazyOnload
. It delays the loading of the script to the first time the browser’s main thread becomes idle.
Another interesting strategy is called worker
which moves the loading of the script off-main-thread to a web worker using Partytown. It’s not compatible with the app
folder as of July 2023. But when you are in a position enabling its use, this opens up interesting optimization possibilities. Mostly if the script is doing quite independent stuff from the rest of the application like analytics, A/B testing, or metrics collection.
🧠 Performance Mindset
These are the options offered by the framework for influence loading behavior. Remember, when you are reaching out for: React components, libraries, images, fonts or scripts you can and likely should use some of these tools on a Next.js based project.
Final Warning: When Not to Lazy Load
Before you go out and naively try to:
You need to realize, as with all design decisions there are tradeoffs to be made here. I will explain what you need to keep in mind when deciding for or against lazy loading something.
👉 If you want to see an easy to follow, step by step example of the core issues with naively lazy loading everything check out this excellent article from the authors of Qwik. Highly recommended. As well as the framework itself. It’s so innovative I’m sure it will drive lots of changes in other frameworks too.
Lazy loading only works properly with client side rendering. If you SSR a lazy loaded component you might come out worse in the end than without lazy loading or server side rendering. The JS for the lazy loaded component will be fetched eagerly on the client, as it’s needed for the hydration of the initial HTML. That means we created another unnecessary network request, that’s pure overhead in the process of reaching interactivity. If you had it loaded normally, its code would have been part of the main bundle and be available at this point. However if the lazy loaded component is purely CSR, this whole situation won’t happen. Issue solved.
Lazy loading is only useful for components not present in the current render tree in SSR mode. It usually comes down to two cases. One is lazy loading the next route and the other one is conditional rendering of a lazy loaded component. The reason is the same as before. If the code split chunk of the lazy loaded component is eagerly fetched for hydrating the initial SSR’ed HTML we end up shooting ourselves in the foot. However if the component is not present in it, we can still reap the proper benefits of lazy loading and in these two cases that’s surely what will happen.
You should never lazy load content needed for SEO. There’s just no telling whether the crawler will surely wait for the loading to finish or not. If you SSR the lazy loaded component to circumvent this, then we are back to problem #1.
Many lazy loaded components on the same view lead to hard to answer bundling questions. If multiple lazy loaded components are requested for the same URL, it might make sense to put them into the same chunk depending on when they will be requested. I’m sure you already feel the combinatorial explosion building up in your head. What would be best paired with what when the app navigates from A to B or from C to A. What about things that are fetched on user interaction. What about prefetching? Not every bundler is capable of handling this question properly so now we developers are responsible to think about this concern too.
Each code split chunk has some overhead. You need to be mindful that every chunk contains some additional JS code. The extra size can add up with each new chunk and their loading incurs network overhead too. All these can lead to situations where too many, small, lazy loaded chunks negate every performance benefit they provide otherwise. Evaluating the costs & benefits of a certain chunking setup is tricky, but luckily we don’t have to pioneer the solution. Here’s a guide on Hackernoon how to measure and fine tune chunk sizes and their performance impact.
That’s all the advice I have for you, student of the way of ancient performance grandmasters. Now you know how to lazy load, when to lazy and what to lazy load. Use it well and see you in the next one!
Challenge: Share The Diff
I would like to ask you to take what you learned here and put it to practice. Use the Lighthouse Diff Tool to record how you made a difference by applying lazy loading. If you share your results and a little background story here in the comments I will highlight the best 3 of those in the article!