Bundle-size advice tends to stop at "use React.lazy," without saying when not to. I found the boundary the hard way, splitting a component small enough that its own lazy chunk's request overhead outweighed whatever bytes it removed from the main bundle. The fix was un-splitting it.
TL;DR: Route-level code-splitting (one chunk per page) is close to a free win in almost every React app. Component-level splitting only pays off for genuinely heavy, conditionally-rendered pieces, a rich text editor, a charting library, an admin-only panel, not for small components or anything that renders on every visit. Measure the actual chunk size before splitting; a 2KB component gains nothing from its own request.
Route-Level Splitting: Do This by Default
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const HomePage = lazy(() => import('./pages/HomePage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/admin" element={<AdminDashboard />} />
</Routes>
</Suspense>
);
}
A user visiting / never downloads AdminDashboard's JavaScript, which might include heavy charting or table libraries only relevant to admin users. This is close to a no-downside win: each route is already a natural, meaningful boundary a user visits deliberately, so the extra request per navigation is a request they were going to make anyway (a page navigation), not one added purely for the sake of splitting.
Component-Level Splitting: Measure First
import { lazy, Suspense, useState } from 'react';
// Good candidate: a rich text editor is genuinely heavy (often 100KB+)
// and only needed if the user opens the "write a review" form
const RichTextEditor = lazy(() => import('./RichTextEditor'));
function ProductPage() {
const [showReviewForm, setShowReviewForm] = useState(false);
return (
<div>
<button onClick={() => setShowReviewForm(true)}>Write a review</button>
{showReviewForm && (
<Suspense fallback={<EditorSkeleton />}>
<RichTextEditor />
</Suspense>
)}
</div>
);
}
Two things make this a good split: the component is genuinely large (a rich text editor commonly pulls in a substantial dependency tree), and it's conditionally rendered, most visitors to a product page never click "write a review" at all, so most users never download that chunk.
The Bad Split: Small and Always-Rendered
// Bad: this component is small, and renders on every single page load
const Footer = lazy(() => import('./Footer'));
function Layout({ children }: { children: React.ReactNode }) {
return (
<div>
<Header />
{children}
<Suspense fallback={<div style={{ height: 120 }} />}>
<Footer />
</Suspense>
</div>
);
}
The footer renders on every page, for every user, unconditionally. Lazy-loading it doesn't defer anything meaningful, it just adds a network request and a loading state to something that was always going to load anyway. Measure the actual chunk size with a bundle analyzer before assuming any split helps: if Footer.js compiles to 3KB gzipped, the main bundle barely shrinks and you've added latency for nothing.
Measuring the Actual Trade-off
npx vite-bundle-visualizer
# or, for webpack:
npx webpack-bundle-analyzer dist/stats.json
Run this before and after a split. The question to answer isn't "did I split it," it's "did the main bundle shrink by more than the new chunk's overhead costs in latency for the users who need it." A component that's 40KB uncompressed and used by 5 percent of visitors is a clear win split out. A component that's 3KB and used by 90 percent of visitors is not, splitting it just adds a request for almost everyone with negligible bundle savings for anyone.
Preloading a Lazy Chunk Before It's Needed
For the middle case, a component that's genuinely heavy but likely to be needed soon (not on initial render, but predictably within the next few seconds), preloading hides the network latency without loading it eagerly:
const RichTextEditor = lazy(() => import('./RichTextEditor'));
function ReviewButton() {
function handleMouseEnter() {
// Start the fetch on hover, before the user actually clicks
import('./RichTextEditor');
}
return (
<button onMouseEnter={handleMouseEnter} onClick={() => setShowReviewForm(true)}>
Write a review
</button>
);
}
The import() call on mouseenter kicks off the network request early, so by the time the user actually clicks and the component renders, the chunk is likely already cached. This is a genuine middle ground: the component still doesn't load for users who never hover the button, but it doesn't make the ones who do click wait for a cold request either.
Old Way vs Modern Way
| Scenario | Split? | Reason |
|---|---|---|
| Each route/page | Yes | Natural navigation boundary, users don't need other pages' code |
| Heavy, conditionally-rendered component (editor, chart library) | Yes | Real bytes saved for the majority who never trigger it |
| Small component, always rendered | No | Request overhead exceeds any bundle savings |
| Admin-only feature in a consumer-facing app | Yes | Most users never download admin code at all |
Conclusion
Route-level splitting is close to a default-yes. Component-level splitting needs the same question asked of it every time: is this component both heavy and conditionally needed, or does splitting it just add a request to something that was always going to render. Measure the chunk size before deciding, a linter suggestion isn't a substitute for that number.