How Code Splitting Works With React Lazy And Suspense

Understand how to decrease your initial bundle size


If you care about your application's performance, you may want to try code splitting with React.lazy and the Suspense component. This can be a tricky topic to understand because there are the fundamental concepts, how libraries like React implement them, and how meta-frameworks abstract it away. Today we're primarily going to focus on the core concepts of code splitting and React's fundamental implementation. I'll talk a bit about React Router as an example of some abstractions at the end.

If you understand the core idea of code splitting, you can figure out how any framework implements it

What is "code splitting"

When you have a SPA application you write all your JS files and then use a bundler (we'll be using Vite today) to squash it down into a single file like main-52298105.js. When a user hits your site, the first thing you serve up to them is that file. This generates the entire application on the client, and from there it's history.

However, let's think about that for a second. Why are we sending over our whole application? A user can't hit every single page, every utility component, every modal all at once. So what if instead we only sent the bare minimum a user needed, and then brought in the rest later? All code splitting does is break up your bundle into "chunks."

There are lots of methods to do it, you can use manual entry points and tell your bundler explicitly how to break up your bundle, but with React we don't have to do that. Instead we go up to the logic layer, and say "Hey, load these components first, and these other ones later", and then React and your bundler take it from there. The most common ways of code splitting in React are by route and by component. We'll talk about both.

What are the tradeoffs?

Code splitting affects load times. Without code splitting, you have a longer initial load, and then you don't need to request any more JS from the server. With code splitting, you will have a faster initial load, but then some minor load times at other points. There are however situations where you can have your cake and eat it too, where you have fast initial load times, and you can hide the other load times.

And remember, code splitting is not the same as pre-fetching data. There are of course similarities (both are trying to cut down on user load times) but we're not talking about data here, we're talking about the application's JavaScript.

Base Code

That's enough talking, let's start coding. Create your project with npm create vite and select the React option. From there, let's make a Toast component:

And then load it into our App with a standard import.

Now that we have an app, let's build it to see what it normally looks like, and then split it out.

What's a Toast? It's a little message that pops in and out to tell you that you did or did not do something successfully (ours wont be animated for simplicity). It's something you would actually want to split out, since a user will never do anything the second they load up your site. This is a real world use case!

The basic build

Run npm run build, and then open up the newly created dist folder. You should see something like dist/assets/index-5111e541.js. That's gonna be a monster file because it's loading all of React, but the point is, there's only one file. Let's fix that with React.lazy.

Using the lazy function

Instead of import Toast from './Toast', we're going to do this instead:

lazy takes an asynchronous callback that must resolve a default export of a React component. Luckily for us the import function is asynchronous, so it works perfectly. Just make sure that your component file has it's component as a default export and not a named export, otherwise it won't work. Also you can keep the file name the same, I'm swapping to AsyncWhatever to make it clear what is and isn't lazy loaded.

Ok, now that we have our first lazy loaded component, let's see what happened to our bundle. Run npm run build and check dist/assets/. In addition to our main index file, we now have a Toast-493194.js file. Cool huh?

Some explanations

Real quick, if you're wondering what all those numbers after the name are, they're for caching. Don't worry about them, they won't always be the same and that's ok.

Also, if you've beaten me to the punch and looked at the Chrome network console while running your app, you may say "Hey, there was always a Test.js request even without lazy!" And that's correct. That's because Vite uses ESModules locally. That means that they always request by file. It's faster locally, but you wouldn't want that level of splitting in prod, as network requests aren't worth it. Your app may have dozens (or hundreds) of files by the end. Like all things, moderation is key. We don't want one, we don't want 100, we're looking for only a few, well-packed requests.

Loading a component on demand

Breaking up our components like this is neat, but we can also load our components on demand. Like, what if a user never does anything that sets off a toast? Why bother loading it at all? Let's see if we can only load the toast after a user clicks a button. Replace your App file with this code:

Run this with npm run dev, but before you actually click the button, check the Chrome network tab. Notice something missing? We're no longer loading Toast.js. That's because lazy knows the component wasn't ever actually rendered to the screen. That means our app isn't going to bother making the request. This is great because it will save you a ton of bandwidth for your application by ignoring unnecessary requests.

The problem with this is that it's completely broken our app.

Using Suspense with lazy

If you click that button your page will go blank and you'll receive this weird error in the Chrome console:

Error: A component suspended while responding to synchronous input.
This will cause the UI to be replaced with a loading indicator.
To fix, updates that suspend should be wrapped with startTransition.

That bit about "a component suspended" is the problem. See, React now has the ability to "suspend" pending components that are still mid-request. And the way to deal with suspensions is with the Suspense component.

Click the button, now it works just fine! Suspense is pretty straightforward to use. You wrap it around any components that you want to lazy load. Whenever it detects that a child component goes into a suspended state, it will switch to rendering a fallback component. Right now, we haven't given it a fallback component, so the default is just to render nothing. But let's actually give it one!

Rendering a fallback component

Suspense simply takes a fallback prop, and what you pass in is an actual React element (so JSX, not the component function). This makes it super easy to pass in props. Just make sure that your fallback component is light to load, and not lazy loaded itself (that would kind of defeat the point). Here's my simple loading component:

In real life you'd probably want something a little nicer, like a CSS spinner or something, but for now this will do. To use it, you just pass it, and any props, right into Suspense:

Click the button now and you'll see our loading message first. Well, actually your local app is probably too fast to see it. So let's slow our app down for a second.

Throttling our browser

Refresh your app (don't click the button!), and then go to the Chrome console network tab, and then where it says "No Throttling" and a little arrow, click that, and then select "Slow 3G" from the options. Your browser is now going to simulate roughly 3G speeds for all network requests. Now when you click the button, you'll see the message!

JUST REMEMBER TO SET IT BACK TO "No Throttling". I can't tell you how many times I've forgotten to do that. In fact, even before refreshing your page I would recommend turning it off.

Do you have to use suspense?

The short answer is yes. Even though we started off without it and it seemed to work, there are just so many weird edge cases you may hit, that it's not worth it to skip it. HMR specifically hates when you don't have it, and honestly pretty much all the documentation written today about lazy loading assumes you'll use Suspense components as a wrapper. The fallback component is optional if you want to load something in the background, but the Suspense component itself is a must, at least for now.

Suspense nesting and children

Be careful about the children of Suspense. If any one child suspends, Suspense will replace all children with the fallback. Look here:

That h2 will also disappear when we load in our toast. Think carefully about what you want to be in the component that you load vs what you want to always stay.

You can also nest Suspenses. Essentially the suspended element will trigger the first Suspense it happens upon. To demonstrate this, let's add another async component, this time a modal (you can learn more about modals here). Modals are another candidate for async loading because a user may never load one.

And then let's add that into our cleaned up App code.

Looks a little more complex right? Split apart the modal details from the Suspense details. A critical skill in coding is staying on the task at hand without getting distracted.

As you can see here we now have 2 Suspenses. If we were to load the Toast then the nested Suspense will be hit. However, if you load the modal code, only the top Suspense is hit.

What would happen if you load Toast and then the Modal?

Remember if any child of a Suspense suspends, then it removes all children to render the fallback. So the loaded toast message would disappear while the modal loads in.

Nesting suspenses directly like this isn't super common, but in the course of your application you may have indirectly nested children. Keep an eye out for them!

Error boundaries

Here's a question: what if your user loses their network connection mid-request? You would think that the component returns null or something, but no. It throws an error.

Seeing the problem

It's pretty easy to fake a network outage, load up your app, and then open up the Chrome network tab again. This time select "Offline." Now, click one of the load buttons and watch the entire application crash! Awful! Now reset the network and refresh the page.

Making a boundary

Error boundaries aren't a Suspense thing, you should use them anywhere React itself may crash. I mean, honestly, I know no one does, But you should. I think the reason they aren't more popular is because they use ... classes.

That's not so bad right? getDerivedStateFromError and componentDidCatch don't have hooks (yet) so that's why we have to use a class. But this is all the code you need, so it's not a big deal.

To use it, stick it anywhere that could throw a React error. In our case that's the parent of the top Suspense. This single boundary will catch any non-caught errors all the way down.

Obviously the error fallback element can be a little more useful, this is just illustrative. The point is you pass in an element like you do with Suspense. Now if you shut down the network and click the button, the app itself is fine and the little warning pops up. Much better!

Again, this is a pretty unlikely error, so don't go too crazy handling retries or anything like that. Usually the fix for failed requests is the user refreshing the page.


Splitting by route

OK! That's basically all of the core concepts taken care of. Congrats! Now let's try our hand at how a framework deals with lazy loading. For this example we'll be looking at React Router briefly, v5 and v6.

V5 Router

Basically, throw a Suspense around your Switch and you're good to go. It would look something like this:

Honestly I really like V5's API for my use cases, I get why they upgraded, but really, this was pretty much all I ever needed. Anyway, here you can see how we're taking advantage of Suspense checking for any suspended children. Since we only load one route at a time, we can catch them all with a single Suspense since the loading behavior is identical across the board.

V6 router

V6 is when React Router decided to directly incorporate code splitting ideas into the framework itself. Please check the React Router lazy docs for full syntax, but I'll go over the highlights here (and why I don't love it).

The biggest change is that you don't call lazy directly anymore, it's a prop on a Route. Also, the fallback component and error handling go inside the lazy loaded file now, not their own components.

Which seems helpful, but now, it's the exact opposite of React.lazy. React.lazy says you have to have a react component as the default export. But, React Router will break if you do that. instead, you must have named exports for Component, ErrorBoundary, and loader.

I'm not going to pretend it isn't less code to do that. But what's annoying (particularly with React Router) is who knows how long that will be true. React is pretty set and tries not to have too many fundamental shifts (keyword there being "tries").

React router on the other hand, it seems to drastically change every other year. It won't even exist soon, as it's merging with Remix. That's why it's so important for you to understand fundamental ideas as opposed to syntax. Syntax can change across time and frameworks, but the ideas behind them stay solid.

Complexity vs Performance

As you can see here, code splitting has some great benefits, but it comes at a cost. Complexity and dev time will increase, especially in a real world, non-toy app like all of these examples. Before you add in code splitting, really think about if you need it, and whether or not shifting load times makes sense for your users.

Happy coding everyone,

Mike