Code Splitting in React: Route-Level vs Component-Level and What Actually Matters
Most teams split everything or nothing. Both are wrong.

Most devs discover React.lazy() and go one of two directions. Either they add it to every component they can find, or they add it once to their routes and never think about it again. Neither approach is wrong exactly, but neither is right either.
Code splitting is a surgical tool. The question is never "did I split this?" The question is "does splitting this change what the user experiences?" If the answer is no, you just added complexity for nothing.
What Code Splitting Actually Does
When you build a React app, your bundler (Vite, Webpack, whatever) outputs JavaScript files. By default, everything goes into one bundle. The browser downloads that one file, parses it, executes it, and then your app runs.
Code splitting tells the bundler to produce multiple smaller files instead. The browser downloads what it needs right now, and fetches the rest later when it's actually needed.
The key word is needed. If the user is on the login page, they don't need the code for the admin dashboard. Shipping it anyway means the browser is parsing and executing JavaScript that isn't doing anything useful yet.
Here's what a single-bundle app looks like from the network perspective:
// Everything ships together
// login code + dashboard code + settings code + reports code
// = one 800KB bundle the browser must parse before anything renders
And here's what a split app looks like:
// Browser downloads only what's needed for the current route
// login code = 120KB
// dashboard code = fetched when user navigates there
// settings code = fetched when user navigates there
The user on the login page just saved 680KB of parsing work. That directly impacts Time to Interactive and LCP.
But here's what most posts skip: bundle size is not the only metric that matters. The number of network requests, when those requests happen, and whether the browser can parallelize them all affect the real-world result. Splitting badly can make things slower than not splitting at all.
Route-Level Splitting: The Right Default
Route-level splitting is the highest-leverage thing you can do. It maps directly to user intent. When a user visits /dashboard, they need dashboard code. They don't need settings code, report code, or anything else.
This is how you do it with React Router:
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
// WRONG — importing directly means everything lands in one bundle
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
import Reports from './pages/Reports';
// CORRECT — lazy imports create separate chunks per route
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Reports = lazy(() => import('./pages/Reports'));
const router = createBrowserRouter([
{
path: '/dashboard',
element: (
<Suspense fallback={<PageSkeleton />}>
<Dashboard />
</Suspense>
),
},
{
path: '/settings',
element: (
<Suspense fallback={<PageSkeleton />}>
<Settings />
</Suspense>
),
},
{
path: '/reports',
element: (
<Suspense fallback={<PageSkeleton />}>
<Reports />
</Suspense>
),
},
]);
const App = () => <RouterProvider router={router} />;
With TanStack Router, lazy loading is built into the route definition:
import { createRoute, lazyRouteComponent } from '@tanstack/react-router';
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/dashboard',
component: lazyRouteComponent(() => import('./pages/Dashboard')),
});
Both approaches produce the same result: separate chunks per route, loaded on demand.
Route-level splitting should be the baseline for any app with more than two or three pages. The setup cost is minimal. The payoff is immediate.
Where it stops mattering: if your entire app is one page, or if all your routes are tiny and the bundle is already small. Splitting a 50KB app into five 10KB chunks is not going to move any performance metrics.
Component-Level Splitting: Surgical, Not Default
Component-level splitting is different. You're not splitting by user intent (route) but by render condition. The question is: will the user always see this component on this page?
If the answer is no, splitting is worth considering. If the answer is yes, it usually isn't.
When component-level splitting genuinely helps
Heavy third-party libraries loaded conditionally
This is the clearest case. If you're using a rich text editor, a chart library, a PDF renderer, or a code editor that only appears when a user opens a specific panel or modal, you should not be shipping that library to every user on page load.
// WRONG — ships Monaco editor to everyone even if they never open the code panel
import MonacoEditor from '@monaco-editor/react';
const CodePanel = ({ isOpen }) => {
if (!isOpen) return null;
return <MonacoEditor />;
};
// CORRECT — Monaco only loads when the panel is actually opened
import { lazy, Suspense } from 'react';
const MonacoEditor = lazy(() => import('@monaco-editor/react'));
const CodePanel = ({ isOpen }) => {
if (!isOpen) return null;
return (
<Suspense fallback={<EditorSkeleton />}>
<MonacoEditor />
</Suspense>
);
};
Monaco Editor is around 2MB unminified. Shipping that on initial load for users who never open the code panel is indefensible.
Below-the-fold content on content-heavy pages
If you have a long page where the bottom half only renders after the user scrolls, splitting those components reduces the initial parse cost.
import { lazy, Suspense } from 'react';
// Above the fold — ships with initial bundle
import HeroSection from './HeroSection';
import FeaturedProducts from './FeaturedProducts';
// Below the fold — only loads when it enters the viewport
const ReviewsSection = lazy(() => import('./ReviewsSection'));
const RecommendationsSection = lazy(() => import('./RecommendationsSection'));
const ProductPage = () => (
<div>
<HeroSection />
<FeaturedProducts />
<Suspense fallback={<SectionSkeleton />}>
<ReviewsSection />
</Suspense>
<Suspense fallback={<SectionSkeleton />}>
<RecommendationsSection />
</Suspense>
</div>
);
Combine this with an Intersection Observer to trigger prefetching before the user actually scrolls there and you get the best of both worlds: fast initial load and seamless scroll experience.
Modals, drawers, and panels that are rarely opened
If 80% of your users never open the advanced settings modal, don't ship its code to all of them.
import { lazy, Suspense, useState } from 'react';
const AdvancedSettingsModal = lazy(() => import('./AdvancedSettingsModal'));
const SettingsPage = () => {
const [showAdvanced, setShowAdvanced] = useState(false);
return (
<div>
<BasicSettings />
<button onClick={() => setShowAdvanced(true)}>
Advanced Settings
</button>
{showAdvanced && (
<Suspense fallback={<ModalSkeleton />}>
<AdvancedSettingsModal onClose={() => setShowAdvanced(false)} />
</Suspense>
)}
</div>
);
};
The modal code only loads when the button is clicked. If the user never clicks it, they never pay for it.
The Problem Nobody Talks About: Splitting Too Aggressively
Here's where I see teams go wrong once they discover code splitting. They start splitting everything. Every component gets lazy(). Every import becomes dynamic. The bundle analyzer shows dozens of tiny chunks and it feels like a win.
It's not.
Every dynamic import is a network request. Network requests have overhead: DNS resolution, connection establishment, server processing, transfer time. If you split a 5KB component into its own chunk, you've traded 5KB of bundle size for a network round trip that might cost more in latency than you saved in parse time.
The waterfall problem is even worse. Look at this:
// This creates a loading cascade — LazyParent loads, renders,
// then discovers it needs LazyChild, then fetches it
// The user sees two separate loading states
const LazyParent = lazy(() => import('./Parent'));
// Inside Parent.jsx
const LazyChild = lazy(() => import('./Child'));
const Parent = () => (
<Suspense fallback={<Spinner />}>
<LazyChild />
</Suspense>
);
The browser fetches Parent, starts rendering it, hits the lazy Child import, fetches Child, renders it. The user sees a spinner, then another spinner. You've created a waterfall where one didn't need to exist.
The fix is to either import both at the route level together, or use Promise.all to fetch them in parallel:
// Both chunks fetch in parallel — one loading state, not two
const LazyPage = lazy(() =>
Promise.all([
import('./Parent'),
import('./Child'),
]).then(([parent]) => parent)
);
Layout shift is the other cost. If your Suspense fallback is a different height than the actual content, you get CLS. That's a Core Web Vitals hit. Measure it.
Preloading: The Part Most Teams Skip Entirely
Splitting without preloading is like putting your tools in a locked cabinet and waiting until you need them to go find the key. The split is good. The timing is wrong.
Preloading tells the browser to fetch a chunk before the user actually triggers the navigation or interaction that needs it.
The simplest version is triggering the import on hover:
import { lazy, Suspense } from 'react';
import { Link } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
// Preload on hover — by the time the user clicks, the chunk is already fetched
const preloadDashboard = () => import('./pages/Dashboard');
const Nav = () => (
<nav>
<Link
to="/dashboard"
onMouseEnter={preloadDashboard}
onFocus={preloadDashboard}
>
Dashboard
</Link>
</nav>
);
The user hovers over the link. The browser starts fetching the dashboard chunk. By the time they click, the chunk is already in cache. The navigation feels instant.
For below-the-fold content, use an Intersection Observer to prefetch before the user scrolls there:
import { useEffect, useRef } from 'react';
const usePrefetch = (importFn) => {
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
importFn();
observer.disconnect();
}
});
},
{ rootMargin: '200px' } // start fetching 200px before it's visible
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [importFn]);
return ref;
};
// Usage
const prefetchReviews = () => import('./ReviewsSection');
const ProductPage = () => {
const reviewsRef = usePrefetch(prefetchReviews);
return (
<div>
<HeroSection />
<div ref={reviewsRef}>
<Suspense fallback={<SectionSkeleton />}>
<ReviewsSection />
</Suspense>
</div>
</div>
);
};
The chunk fetches while the user is still reading the top of the page. By the time they scroll down, it's ready.
Vite also lets you hint at preloads directly in the import:
// Vite magic comment — tells the bundler to preload this chunk
const Dashboard = lazy(() => import('./pages/Dashboard' /* @vite-prefetch */));
Webpack has the same feature:
const Dashboard = lazy(() =>
import(/* webpackPrefetch: true */ './pages/Dashboard')
);
These hints add a <link rel="prefetch"> tag to the HTML head, telling the browser to fetch the chunk during idle time using low-priority bandwidth. The user gets faster navigation without any visible loading state.
How to Measure Whether Your Splitting Is Actually Working
The worst mistake is adding splitting and then assuming it helped. Measure it.
Bundle analyzer first
# Vite
npm run build -- --report
# Or use rollup-plugin-visualizer
npm install rollup-plugin-visualizer --save-dev
Look at the output. Are your chunks reasonable sizes? Are there obvious candidates that should be split (large third-party libs loading on routes that don't use them)? Are there too many tiny chunks that could be consolidated?
Network tab in DevTools
Load your app on a throttled connection (Fast 3G in Chrome DevTools). Watch what loads and when. Are chunks loading in sequence when they could load in parallel? Are you seeing waterfalls? Is your initial bundle still large despite splitting?
LCP and INP in the field
Route-level splitting directly affects LCP because it reduces the JavaScript the browser must parse before it can render the page. Measure LCP before and after. If it didn't move, your initial bundle wasn't the bottleneck and you should look elsewhere.
INP is affected by interaction responsiveness. If clicking a nav link triggers a chunk download and the UI freezes for 300ms, that's an INP problem. Preloading on hover solves it.
Core Web Vitals in Chrome User Experience Report
Field data beats lab data. The CrUX dataset shows real user metrics on real devices and connections. If your LCP is good in Lighthouse but bad in CrUX, your code splitting strategy might be working fine in the lab but producing waterfalls or missing chunks in production.
The Decision Framework
Stop guessing. Use this:
Always split at the route level. No exceptions for apps with multiple routes. The setup is five minutes. The payoff is real.
Split at the component level when all three are true:
The component is not always visible on initial render
The component (or its dependencies) is larger than ~30KB gzipped
The user doesn't always need it in their session
Do not split at the component level when:
The component is small and its dependencies are already in the bundle
It's always visible on the page without user interaction
Splitting would create a waterfall where a single load existed before
Always add preloading when you split. Splitting without preloading moves the latency cost from parse time to interaction time. Neither is ideal. Preloading eliminates the interaction cost.
Measure before and after. Not with Lighthouse score. With real metrics: LCP, INP, bundle size per route, chunk count, and waterfall analysis in the network tab.
Conclusion
Route-level splitting is table stakes. If your app has multiple routes and you're shipping one bundle, you're making every user pay for code they're not using. Fix that first.
Component-level splitting is where the real engineering judgment kicks in. The question is never "can I split this?" You can split anything. The question is "does splitting this change what the user experiences for the better?" Most of the time with small components, the answer is no.
Preloading is what makes the whole thing work in practice. A split bundle that the browser has to fetch at the moment of navigation is still a slow navigation. Get ahead of the user.
Split with intention. Measure the result. Add preloading. In that order.





