Code Splitting and Lazy Loading in Modern React Applications
27 Mart 202612 dk okuma10 okuma
Performans ve OptimizasyonTeknolojiReactYazılım Geliştirme
You open a web app. The page takes four seconds to become interactive. Then you click around, use maybe some features, and close the tab. The browser had already parsed, compiled, and executed 2.4 megabytes of JavaScript to make that happen. Most of it that's because of features that you never touched.
But this isn't a theoretical problem. Essentially it's the default behavior of almost every React application that doesn't deliberately address it.
To prevent pushing dozens of redundant js files to the user, we use code splitting. But to understand how it works and more importantly, how to apply it correctly: We need to start from how a bundler thinks about your code.
When you run vite build or webpack, the bundler doesn't just concatenate your files. It builds a dependency graph
Starting from entry point (main.tsx), it follows every static import, then every import inside those files, recursively, until it has visited every module your application depends on. Then it serializes all of that into output files.
Without an intervention, the bundler walks this entire tree and outputs:
dist/└── bundle.js ← everything, 2.4MB
Every route, every library, every component are packed up, whether the current user will ever see it or not. The bundler is just doing its job: it found everything connected to your entry point, and it bundled it.
But the question code splitting answers is: Does everything connected to your entry point need to arrive at the same time?
2. Static imports vs dynamic imports
This is the mechanical core of code splitting, and it comes down to a single distinction in JavaScript syntax.
A static import is resolved at compile time. The bundler sees it, follows the edge, and pulls the target module into the same chunk as the source:
importDashboardfrom'./pages/Dashboard'; // bundler follows this edge
A dynamic import is a function call that returns a Promise. The bundler sees it too, but instead of inlining the target, it opens a new chunk boundary:
constDashboard = import('./pages/Dashboard'); // bundler opens a new chunk here
That's basically the entire mechanism. import() tells the bundler: "this module should live in a separate file, fetched on demand at runtime." Everything the target module imports will follow it into the new chunk, unless it's already been assigned elsewhere.
With Vite, the output for a route-split application looks like this:
A user who visits the homepage downloads index.js and react-vendor.js. That's only 237 KB. So if the user is not authorized yet or basically not admin, AdminPanel-Fq2mN7.js won't be downloaded.
3. Wiring dynamic imports into the component tree: React.lazy
import() gives you a Promise, but react components work synchronous. React.lazy bridges that gap: it wraps a dynamic import and makes the result usable as a regular component.
// Without React.lazy — raw dynamic import, not usable as JSX directlyconst dashboardModule = import('./pages/Dashboard');// With React.lazy — returns a component React can renderconstDashboard = React.lazy(() =>import('./pages/Dashboard'));
React.lazy expects a function that returns a Promise resolving to a module with a default export. When the component is first rendered, React triggers the import, suspends the subtree while the chunk loads, and renders it once the Promise resolves.
The suspension mechanism requires Suspense:
import { lazy, Suspense } from'react';import { BrowserRouter, Routes, Route } from'react-router-dom';// These become separate chunks automatically — no config needed in ViteconstDashboard = lazy(() =>import('./pages/Dashboard'));constAdminPanel = lazy(() =>import('./pages/admin/AdminPanel'));constAdminSettings = lazy(() =>import('./pages/admin/AdminSettings'));// Home is small — static import is fineimportHomefrom'./pages/Home';functionApp() {return (<BrowserRouter><Navbar /><Suspensefallback={<PageSpinner />}>
<Routes><Routepath="/"element={<Home />} />
<Routepath="/dashboard"element={<Dashboard />} />
<Routepath="/admin"element={<AdminPanel />} />
<Routepath="/admin/settings"element={<AdminSettings />} />
</Routes></Suspense></BrowserRouter> );}
When a user navigates to /dashboard, React attempts to render <Dashboard />. However, It doesn't exist yet. React suspends the subtree, the nearest Suspense boundary renders <PageSpinner />, and the browser fetches Dashboard-Dp8aK1.js. Once it arrives, React resumes and renders the component.
One Suspense boundary at the router level is the right starting point. You can add more granular boundaries later, for example, wrapping a heavy modal separately from the page it lives on.
4. What actually ends up in each chunk and why it matters
Here's something that catches developers off-guard: When you split AdminPanel into its own chunk, its dependencies come with it.
AdminPanel.tsx imports ag-grid-react. ag-grid-react imports its own dependencies. Vite follows every edge from AdminPanel and bundles all of them into AdminPanel-Fq2mN7.js.
This is actually correct behavior. Users who never visit /admin download none of this. The split is working.
The problem surfaces when multiple chunks share the same library. Suppose both Dashboard and AdminPanel import recharts:
Dashboard-Dp8aK1.js├── Dashboard.tsx code└── recharts + d3 ~420KB ← full copyAdminPanel-Fq2mN7.js├── AdminPanel.tsx code└── recharts + d3 ~420KB ← full copy again
A user who visits /dashboard and then /admin downloads recharts twice. The browser can't cache it across chunks because it lives at two different URLs.
This is what manualChunks is for.
5. Controlling where libraries live: manualChunks
manualChunks in Vite's config is a function that receives a module ID (its file path) and returns the chunk name it should be assigned to. Vite guarantees that a module only lives in one chunk.
The react-vendor chunk is also strategically important for caching. Your application code changes with every deploy, but react and react-dom don't change between your releases. By isolating them in their own chunk, you ensure that users' browsers cache them across deploys. The content hash in the filename (react-vendor-C7mQR2.js) only changes when the library itself updates.
6. Loading chunks before they're needed: Prefetching
Code splitting solves the "don't download what you don't need" problem. Prefetching solves the secondary problem: The perceptible delay when a user navigates to a lazy-loaded route for the first time.
When a user clicks a link to /admin, three things happen in sequence:
The click is registered,
the import() call fires,
the browser makes a network request.
On a fast connection this is imperceptible, but on a slow connection or a large chunk, the Suspense fallback becomes visible for seconds. Prefetching shifts the network request earlier, to a moment when the user is likely to navigate to that route, but hasn't yet. So by the time they click, the chunk is either fully loaded or most of the way there.
The two most useful triggers are after login and on hover:
// Navbar.tsximport { useEffect } from'react';import { Link } from'react-router-dom';interfaceNavbarProps {userRole: 'user' | 'admin';}functionNavbar({ userRole }: NavbarProps) {useEffect(() => {// Admin chunks prefetched after login -> runs once, in the background// Won't compete with initial render; useEffect fires after paintif (userRole === 'admin') {import('./pages/admin/AdminPanel');import('./pages/admin/AdminSettings'); } }, [userRole]);return (<nav><Linkto="/">Home</Link>
{/* Hover prefetch -> fires ~200ms before a likely click */}
<Linkto="/dashboard"onMouseEnter={() => import('./pages/Dashboard')}
>
Dashboard
</Link>
{userRole === 'admin' && (
<Linkto="/admin"onMouseEnter={() => import('./pages/admin/AdminPanel')}
>
Admin
</Link>
)}
</nav> );}
A few things worth noting here:
The useEffect approach fires after the component mounts, which means after the initial render and paint have completed. The admin chunks download in the background without competing with the resources needed to show the current page.
The onMouseEnter approach exploits the ~200–300ms gap between hovering over a link and clicking it. That window is often enough to fetch a small or medium chunk entirely.
Calling import() on a chunk that's already been fetched is a no-op because browser returns the cached module. So having both useEffect prefetch and onMouseEnter prefetch for the same chunk is safe, they won't trigger duplicate requests.
7. A real-world scenario
Let's walk through the full picture with the homepage and admin panel use case.
Notice the conditional route rendering. Admin routes aren't registered for non-admin users at all. There's nothing to navigate to, nothing to prefetch, no accidental chunk request.
App renders, Home displays immediately (static import, already in index.js).
User navigates to /dashboard -> charts-vendor.js + Dashboard.js fetched (428 KB).
admin-vendor.js and the admin chunks never touch the network.
What an admin user experiences:
Same initial 237 KB.
After login, useEffect in Navbar fires -> AdminPanel.js, AdminSettings.js, and their vendor chunk begin downloading in the background.
User sees the dashboard, navigates around.
User clicks the Admin link -> chunk is already cached. No spinner. Instant render.
The admin's total download is larger, but they only pay for it once, cached across sessions, and it doesn't slow down their initial load.
8. When not to split
Code splitting adds a network round trip. For small modules, that cost outweighs the benefit.
A <Button /> component which is 2 KB doesn't belong in its own chunk. The HTTP request overhead and the brief suspension will cost more than the bytes saved. A naive rule: a module should be at least 30–50 KB before lazy-loading it is worth considering. Below that threshold, static imports are almost always the right choice.
There's also the question of what's above the fold. If a component is visible on the initial render, it should never be lazy. The point of code splitting is to defer things the user hasn't asked for yet. Lazy-loading a component that should render immediately only creates an unnecessary loading spinner.
A useful mental model: lazy loading is about delaying optionality. The admin panel is optional for most users. The dashboard chart is optional until the user navigates there. The modal is optional until the user opens it. The homepage header is never optional, it's always there.
9. Summary
Code splitting and lazy loading aren't a single feature. They're a set of interlocking mechanisms, each solving a different part of the same problem.
Concept
What it solves
import()
Creates a chunk boundary, tells the bundler to separate this module
React.lazy
Makes a dynamic import renderable as a component
Suspense
Handles the gap between "component requested" and "component ready"
manualChunks
Prevents shared libraries from being duplicated across chunks
onMouseEnter prefetch
Hides network latency behind "user hesitation"
useEffect prefetch
Pre-loads likely chunks after login, before they're needed
Conditional routes
Prevents admin chunks from being requestable by non-admin users
These aren't independent optimizations to apply one by one. They compose. A well-split application uses all of them together: React.lazy to define the boundaries, manualChunks to control library placement, and prefetch strategies to make the latency invisible.
The result isn't just a smaller initial bundle. It's an application where every user downloads exactly what they need and the rest arrives precisely when it's required.