Ship less JavaScript on first load by splitting your bundle by route and by feature, and loading the rest on demand.

Bishal Biswas
Self Learner
The problem
By default every component ends up in one bundle. Visit the home page and you download the checkout flow, the admin panel, the chart library and the rich text editor, none of which you are using.
On a fast laptop this is invisible. On a mid range phone on mobile data it is seconds of blank screen.
lazy
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard.jsx'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<Dashboard />
</Suspense>
);
}
lazy takes a function returning a dynamic import(). The bundler puts that module in a separate chunk, downloaded only when the component first renders.
The Suspense boundary is required, since there is a moment where the code has not arrived.
Default exports
export default function Dashboard() {}
const Dashboard = lazy(() => import('./Dashboard.jsx'));
lazy expects a default export. For a named one:
export function Dashboard() {}
const Dashboard = lazy(() =>
import('./Dashboard.jsx').then((module) => ({ default: module.Dashboard }))
);
Split by route first
This is where the biggest win is, because a user only ever sees one route at a time.
import { lazy, Suspense } from 'react';
import { createBrowserRouter } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home.jsx'));
const Products = lazy(() => import('./pages/Products.jsx'));
const Checkout = lazy(() => import('./pages/Checkout.jsx'));
const Admin = lazy(() => import('./pages/Admin.jsx'));
const router = createBrowserRouter([
{
element: (
<Suspense fallback={<PageSkeleton />}>
<RootLayout />
</Suspense>
),
children: [
{ index: true, element: <Home /> },
{ path: 'products', element: <Products /> },
{ path: 'checkout', element: <Checkout /> },
{ path: 'admin', element: <Admin /> }
]
}
]);
The admin panel now costs nothing to visitors who never open it.
Note the single Suspense boundary in the layout, so every route shares one fallback.
Split heavy features
const RichTextEditor = lazy(() => import('./RichTextEditor.jsx'));
const Chart = lazy(() => import('./Chart.jsx'));
function ArticlePage() {
const [isEditing, setIsEditing] = useState(false);
return (
<div>
<article>...</article>
<button onClick={() => setIsEditing(true)}>Edit</button>
{isEditing && (
<Suspense fallback={<p>Loading editor...</p>}>
<RichTextEditor />
</Suspense>
)}
</div>
);
}
Editors, charting libraries, date pickers, PDF viewers and map components are all large and often used by a minority of visitors.
Preloading on hover
Lazy loading trades bundle size for a delay when the component is needed. Preloading removes most of that delay.
const Checkout = lazy(() => import('./Checkout.jsx'));
function CheckoutLink() {
function preload() {
import('./Checkout.jsx');
}
return (
<Link to="/checkout" onMouseEnter={preload} onFocus={preload}>
Checkout
</Link>
);
}
Calling import() starts the download. By the time the user clicks, the chunk is usually already there. onFocus covers keyboard users.
You can also preload after the page settles:
useEffect(() => {
const id = setTimeout(() => {
import('./Checkout.jsx');
}, 2000);
return () => clearTimeout(id);
}, []);
The initial load stays small, and the likely next step is ready before it is asked for.
Handling failures
A chunk request can fail on a flaky network, or after a deploy replaced the file.
<ErrorBoundary
fallback={
<div>
<p>Could not load this section.</p>
<button onClick={() => window.location.reload()}>Reload</button>
</div>
}
>
<Suspense fallback={<Spinner />}>
<Dashboard />
</Suspense>
</ErrorBoundary>
The stale chunk case is worth knowing. A user with the page open when you deploy may request a filename that no longer exists. Reloading fixes it, so make the fallback offer that.
Do not over split
const Button = lazy(() => import('./Button.jsx'));
const Icon = lazy(() => import('./Icon.jsx'));
Every chunk is a separate request with its own overhead. Splitting a 2KB button costs more than it saves, and a page assembling itself from thirty tiny chunks feels worse than one that arrives at once.
Split things that are large, optional, or route specific. Leave small shared components in the main bundle.
Measuring
npm run build
The build output lists every chunk and its size. Look for anything unexpectedly large.
For detail, use a visualiser:
npm install --save-dev rollup-plugin-visualizer
That draws a treemap of what is in each chunk. It is often surprising: a date library imported for one function, or an icon set where you use four icons.
Server Components go further
With Server Components, the code stays on the server entirely.
import { marked } from 'marked';
export default async function Article({ slug }) {
const post = await getPost(slug);
return <div dangerouslySetInnerHTML={{ __html: marked(post.body) }} />;
}
marked never reaches the browser at all. That beats lazy loading it, because the best chunk is the one you never send.
Quick recap
- One bundle means users download code for pages they never visit.
lazyplus a dynamic import creates a separate chunk.- It needs a
Suspenseboundary and a default export. - Split by route first, then by heavy optional features.
- Preload on hover or focus so the delay disappears.
- Wrap lazy sections in an error boundary offering a reload.
- Do not split small components, since each chunk has overhead.
- Server Components remove code from the bundle entirely.
import { lazy, Suspense, useState, useEffect } from 'react';
// lazy takes a function returning a dynamic import. The bundler puts
// that module in its own chunk, fetched only when first rendered.
const Dashboard = lazy(() => import('./pages/Dashboard.jsx'));
const Admin = lazy(() => import('./pages/Admin.jsx'));
const RichTextEditor = lazy(() => import('./RichTextEditor.jsx'));
// For a NAMED export, map it to default yourself:
const Chart = lazy(() =>
import('./Chart.jsx').then((module) => ({ default: module.Chart }))
);
export default function ArticlePage() {
const [isEditing, setIsEditing] = useState(false);
// Preload after the page settles, so the likely next step is ready
// before the user asks for it
useEffect(() => {
const id = setTimeout(() => import('./RichTextEditor.jsx'), 2000);
return () => clearTimeout(id);
}, []);
// Preload on hover AND focus, so keyboard users benefit too
function preloadEditor() {
import('./RichTextEditor.jsx');
}
return (
<div>
<article>The article content</article>
<button onMouseEnter={preloadEditor} onFocus={preloadEditor}
onClick={() => setIsEditing(true)}>
Edit
</button>
{/* An error boundary matters here: a chunk can fail on a flaky
network, or after a deploy replaced the file. Offer a reload. */}
<ErrorBoundary
fallback={
<div>
<p>Could not load the editor.</p>
<button onClick={() => window.location.reload()}>Reload</button>
</div>
}
>
{isEditing && (
<Suspense fallback={<p>Loading editor...</p>}>
<RichTextEditor />
</Suspense>
)}
</ErrorBoundary>
</div>
);
}
// SPLIT BY ROUTE FIRST. A visitor only ever sees one route, so the admin
// panel should cost nothing to everyone who never opens it.
// const router = createBrowserRouter([
// {
// element: (
// <Suspense fallback={<PageSkeleton />}>
// <RootLayout /> one shared fallback for every route
// </Suspense>
// ),
// children: [
// { index: true, element: <Home /> },
// { path: 'admin', element: <Admin /> }
// ]
// }
// ]);
// DO NOT OVER SPLIT. Every chunk is a request with its own overhead, so
// splitting a 2KB button costs more than it saves. Split things that are
// large, optional, or route specific.
// AND REMEMBER: with a Server Component the code never ships at all,
// which beats lazy loading it.
Previous
memo, useMemo and useCallback in the Compiler Era
Next
Activity for Pre-rendering and State Preservation
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready