If you’ve a large application and you’re trying to keep the performance budget under 100kb for the initial page load, then you would probably need to do code splitting. Code splitting is a performance optimization technique.
Checkout React’s bundle size: Bundlephobiareact v18.3.1 ❘ Bundlephobia
The idea behind code splitting is that you load the bare minimum necessary code to get your app up and running, so the user sees something immediately, and then you hurry in the background to load everything else.
You need to have a strategy to do code splitting. If you’re using Vite then it’ll be easy as React and Vite work well together to make this happen.
It not like you need to have code budget of 100kb, but you should’ve a size budget of something for your application. Something that you’ve to stay under or performance time that you have to hit. You should be thinking about what’s a reasonable amount of time that it takes my app to load for the user.
We’ll be using React with Vite while going through code splitting examples.
Lazy and Suspense
There’re 2 ways to import files in ECMAScript modules i.e. ESM:
- Static import
- Dynamic async import
Static imports are things that we import directly, and which are bundled together leading to increase in the final bundle size.
By using import
(a JavaScript thing and not React) to asynchronously importing we’re telling the bundler that this code isn’t required immediately, and hence will only be loaded when that code is required leading to reduction in our final bundle size.
Consider the following example where we’re import Banner
directly and lazily load Home
(named export) and About
(default export). In this, we’ve a tab and when the tab
is changed to "about"
, the About.tsx
module is fetched and then About
is rendered. Until then the fallback
is shown.
// App.tsx
// Static import
import { useState, lazy, Suspense } from "react";
import { Banner } from "./components/Banner";
// Dynamic imports
const Home = lazy(() =>
// named export
import("./components/Home").then((module) => ({ default: module.Home }))
);
const About = lazy(() => import("./components/About")); // default export
export default function App() {
const [tab, setTab] = useState<"home" | "about">("home");
return (
<main>
<Banner />
<nav>
<button onClick={() => setTab("home")}>Home</button>
<button onClick={() => setTab("about")}>About</button>
</nav>
{tab === "home" ? (
<Suspense fallback={<div>Loading...</div>}>
<Home />
</Suspense>
) : (
<Suspense fallback={<div>Loading...</div>}>
<About />
</Suspense>
)}
</main>
);
}
// components/Banner.tsx
export function Banner() {
return <div className="banner">Banner</div>;
}
// components/About.tsx
export default function About() {
return <div className="about">About</div>;
}
// components/Home.tsx
export function Home() {
return <div className="home">Home</div>;
}
Here, lazy
takes a function that returns a promise resolving to a module. It essentially defers the loading of the component until it is actually rendered. It’s required that we wrap the lazily loaded component with Suspense
and provide a fallback
which will be shown while the module is being loaded.
When we create a production build for this Vite React app by running the pnpm run build
command, we get to see that Vite bundles index.tsx
, App.tsx
, and Banner.tsx
in to a single index-<SHA>
module (since index
is the initial loading so name of the module is index
). Lazily loaded files namely Home.tsx
and About.tsx
are split in to separate module.
> tsc && vite build
vite v5.2.13 building for production...
✓ 35 modules transformed.
dist/index.html 0.37 kB │ gzip: 0.27 kB
dist/assets/Home-B56pF_zH.js 0.13 kB │ gzip: 0.14 kB
dist/assets/About-MN8NNLpC.js 0.13 kB │ gzip: 0.14 kB
dist/assets/index-Cc9jx0Y5.js 144.20 kB │ gzip: 46.50 kB
✓ built in 449ms
Our initial bundle size if 46.5kb.
All of this code splitting is done by Vite and import
being used for dynamic import. lazy
and Suspense
allow us to lazily load component leveraging code splitting feature of the bundler.
Now, the code splitting in the above example is a mistake. This will make our app slower because none of these bundles are big enough and can be loaded in a single bundle giving us everything in the initial load rather than waiting for other bundles to load.
Idea bundles to split are bundles whose size like 30kb, 50kb, or above — large bundles.
Code splitting isn’t a silver bullet. The same is true for server-side rendering. Both aren’t things that we should be doing all of the time. Instead we should measure different metrics and user experience in order to know if these implementations make our users experience better. We’ve to think about initial load and time for interactivity. If you’ve huge application then in order to get performance we’ve to do code splitting.
Common splitting happens based on routes. This is where React Router or NextJS does this automatically.
Prior to React 18, server-side rendering and code splitting didn’t work well together.
Service Workers
While using lazy
, modules are loaded only when the callback passed to lazy
is called. Suspense
will catch and show the fallback
util the module is fetched.
We can get optimistic and load things in the background. This introduces another factor of complexity. The best way to do this is to have a service worker.
The way service worker works is that we’ve our application and server, and service worker sits between them. Any time our application makes a request to our API, it goes through our service worker and out to the API. That’s how service workers work.
What we can do is that when our app loads, we can ask the service worker to load modules and when those modules are actually needed the in the app, service worker will give it to them instead of calling the API. We don’t have to write extra code for that, service worker will do all of it and it will happen instantaneously.
The way to do this is generate a manifest file by Vite, Parcel, or Webpack, for all of the chunks. One the app is loaded, service worker will look at this manifest and get all of the chunks. Remix does takes of this by itself, but it can also be done by ourselves.
Conclusion
Use code splitting for bundles whose individual size are above 30kb, otherwise we’ll slow down our app as we’ve fetch modules here and there. Code splitting should improve user experience, same is the case on why to use server side rendering. Think about initial load and delay in interactivity to drive your decisions.
Using service worker for fetching modules in the background is a great technique but comes with extra complexity to manage.
Next
If you’re interested in learn how React works or how the Fiber Reconciler work, then you can read about them in the following articles: