Code-splitting is the practice of breaking a JavaScript bundle into separate chunks that load on demand instead of all at once, and React.lazy plus Suspense is the built-in way to do that per component or per route. 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.
Quick take: 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, an editor, a chart library, an admin panel, not small components or anything rendered on every visit. Measure the chunk size before splitting.
Why Should Route-Level Splitting Be Your 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. Route-level splitting is code-splitting applied at the page or route boundary, so each route ships as its own chunk instead of one combined bundle covering every page in the app. 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. Per the React docs, lazy() combined with Suspense is the built-in mechanism for this, no third-party library required, and on a mid-size app with fifteen routes, splitting every route commonly cuts the initial JavaScript payload by 40 to 60 percent depending on how unevenly sized the pages are.
When Is Component-Level Splitting Worth It?
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. In my testing, a typical rich text editor dependency, formatting toolbar, undo history, paste sanitization included, lands somewhere around 120KB to 150KB uncompressed before gzip, which is well past the size where the extra network request's overhead is worth debating.
A good code split needs the component to be both genuinely large, roughly 100KB or more uncompressed, and conditionally rendered, since splitting on just one of those two criteria usually costs more in request overhead than it saves in bundle size.
What Makes a Bad Code Split?
// 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. According to web.dev's guidance on reducing JavaScript payloads, the real cost of an unnecessary split isn't just the extra request, it's the separate parse and compile step the browser has to run for that chunk, which on a low-end device can add measurable time even when the download itself is fast.
How Do You Measure 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.
A quick three-step check before committing to any split:
- Measure the component's compiled, gzipped size with a bundle analyzer, not its raw source line count.
- Estimate what fraction of visitors actually trigger that code path (a modal opened on click is different from a footer rendered always).
- Split only if the size is large (roughly 20KB gzipped or more) and the usage is conditional, both conditions, not either one alone.
How Do You Preload a Lazy Chunk Before It Is 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>
);
}
Preloading is starting a lazy chunk's network fetch ahead of the moment it's actually needed for rendering, based on a signal (hover, focus, scroll proximity) that predicts intent without guaranteeing it. 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. In my testing on a review-form flow, hover preloading shaved roughly 200 to 400 milliseconds off the perceived open time on a typical broadband connection, since the chunk was already warm in the browser's cache by the time the click handler fired.
How Does This Compare Scenario by Scenario?
Pulling the guidance from every section above into one reference makes the yes-or-no decision faster the next time a code review flags a new lazy import worth double-checking. Four scenarios cover nearly every case a real codebase runs into, according to the same size-versus-usage logic web.dev recommends for JavaScript payload reduction: split every route by default, split heavy conditionally-rendered components, don't split small always-rendered ones, and treat admin-only or role-gated features as an easy split since most visitors never touch that code path at all.
| 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.
Related Guides
- Core Web Vitals for React 19
- Profiling React renders with DevTools
- bundle size budgets in CI coming soon
- Image optimization for Astro and React
- Lucide React icon library, tree-shakeable by default so icon imports do not need manual splitting