What is code splitting?
Code splitting is the practice of breaking one large JavaScript bundle into multiple smaller chunks that the browser downloads only when it actually needs them.
Theory
TL;DR
- Without splitting, the browser downloads the entire app before rendering anything
- Dynamic
import()tells the bundler to create a separate chunk file at that call site - Route-based splitting is the most common pattern: one chunk per page or route
webpackPrefetchlets the browser download chunks during idle time, before the user navigates- Over-splitting creates its own problem: 50 tiny requests can be slower than one medium bundle
How the Browser Gets the Code
When Webpack or Vite processes your app, every static import ends up in the same output file. The moment you write a dynamic import(), the bundler stops and says: this module gets its own file. At runtime, the browser fetches that file via a new HTTP request the first time that code path executes.
// Static import - goes into main bundle
import { format } from 'date-fns';
// Dynamic import - creates a separate chunk
const { Chart } = await import('./Chart.js');That second line makes the bundler emit something like chunk-abc123.js alongside your main bundle. The browser only downloads it when execution reaches that await import(...).
Route-Based Splitting
React Router plus React.lazy is the standard pattern in React apps:
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}Each page is now a separate chunk. A user who only visits /dashboard never downloads the Settings bundle.
Component-Level Splitting
Route boundaries are not the only place to split. Heavy components like rich text editors, map widgets, or PDF viewers are good candidates too:
const RichEditor = lazy(() => import('./RichEditor')); // 200kb editor library
function PostForm({ showEditor }) {
return (
<div>
{showEditor && (
<Suspense fallback={<p>Loading editor...</p>}>
<RichEditor />
</Suspense>
)}
</div>
);
}The editor loads only when showEditor is true. If most users never click "write a post," they never pay the cost.
What the Bundler Does Internally
Webpack reads import() calls during the build and generates two things: a chunk file and a small runtime snippet that knows how to fetch it. In production you get a hashed filename like 312.abc1d2.js. You can control the name with a magic comment:
const Chart = await import(
/* webpackChunkName: "analytics-chart" */ './Chart'
);Vite works similarly but uses Rollup's chunking algorithm. Next.js handles route splitting automatically: every file in app/ or pages/ becomes its own chunk with no configuration required.
Prefetch and Preload
Loading a chunk only when the user clicks a link means they wait for a network round trip. Webpack supports two hints to avoid that:
webpackPrefetch: true- the browser downloads the chunk during idle time, before the user needs itwebpackPreload: true- the browser fetches it in parallel with the parent chunk
// Downloaded in the background when the browser is idle
const Analytics = await import(
/* webpackPrefetch: true */ './Analytics'
);Prefetch is the right default for most route transitions. Preload is for chunks that are definitely needed alongside the current page.
Common Mistakes
Splitting too fine-grained. If you create 50 separate 5kb chunks, the browser makes 50 HTTP/1.1 requests. Even with HTTP/2 multiplexing, the overhead adds up. A practical rule: anything under 30-40kb is probably not worth its own chunk.
No error boundary with Suspense. If a chunk fails to load (network error, a deploy happening mid-navigation), React throws. Without an error boundary, the whole app crashes.
// App will crash on chunk load failure
<Suspense fallback={<Loading />}>
<LazyRoute />
</Suspense>
// This handles the failure gracefully
<ErrorBoundary fallback={<p>Failed to load. Try refreshing.</p>}>
<Suspense fallback={<Loading />}>
<LazyRoute />
</Suspense>
</ErrorBoundary>Duplicating shared dependencies. If ChartA and ChartB both import lodash, and you split both, lodash ends up in each chunk file. Webpack's SplitChunksPlugin handles this automatically in most configurations, but it is worth verifying with a bundle analysis tool.
Splitting without looking at the bundle first. Split based on data, not instinct. Tools like webpack-bundle-analyzer or Vite's rollup-plugin-visualizer show exactly what lives in each chunk and how large it is.
Real-World Usage
- Next.js - automatic page-level splitting, no configuration needed
- Vite - automatic splitting on dynamic imports;
manualChunksoption for vendor extraction - React Router v6 - combine with
React.lazyfor route-level splitting - Vue -
defineAsyncComponent(() => import('./Component.vue'))works the same way - Angular - lazy-loaded modules via the router's
loadChildrenproperty
Follow-Up Questions
Q: What is the difference between React.lazy and a plain dynamic import?
A: React.lazy wraps a dynamic import and integrates with Suspense, so React handles the loading state for you. A plain import() just returns a Promise and you manage loading state manually. Also, React.lazy only works with default exports.
Q: How does Next.js handle code splitting compared to a standard React app?
A: Next.js splits at the file level automatically: every page in app/ or pages/ is its own chunk. In a plain React setup you add React.lazy manually. Next.js also server-renders HTML, so the user sees content before the JS chunk loads at all.
Q: What happens if the user moves to another route while a chunk is still loading?
A: React cancels the previous Suspense boundary's pending state and starts loading the new chunk. The old network request continues in the background (browsers do not cancel in-flight fetches), but its module is ignored once it arrives.
Q: Can you split code by user role, so admins get different chunks than regular users?
A: Yes. If the user is not an admin, the import() call for admin routes simply never executes, so the chunk is never requested. This is a common pattern for role-based feature gating.
Q: Which performance metric does code splitting most directly improve?
A: Time to Interactive (TTI) and Largest Contentful Paint (LCP) when the removed code was blocking rendering. The improvement shows up in Lighthouse's "Remove unused JavaScript" audit. In practice, first-paint time noticeably improves when the main bundle drops below roughly 150-200kb gzipped.
Examples
Basic Dynamic Import
// Without splitting: heavyLib loads on every page view
// import { processData } from './heavyLib';
// With splitting: heavyLib loads only when the user clicks Export
async function handleExport() {
const { processData } = await import('./heavyLib');
const result = processData(userData);
downloadFile(result);
}
document.getElementById('export-btn')
.addEventListener('click', handleExport);The processData library might weigh 150kb. Without splitting, every user pays that cost on page load even if they never click "Export."
Route Splitting with Error Handling
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import ErrorBoundary from './ErrorBoundary';
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
const UserProfile = lazy(() => import('./pages/UserProfile'));
function App() {
return (
<ErrorBoundary fallback={<p>Page failed to load. Please refresh.</p>}>
<Suspense fallback={<div className="spinner" />}>
<Routes>
<Route path="/admin" element={<AdminPanel />} />
<Route path="/profile" element={<UserProfile />} />
</Routes>
</Suspense>
</ErrorBoundary>
);
}The ErrorBoundary catches chunk load failures. Without it, a deploy that happens while a user is mid-navigation would crash the entire app with no recovery path.
Prefetch on Hover
import { lazy, Suspense } from 'react';
const HeavyDashboard = lazy(() => import('./HeavyDashboard'));
function NavLink({ to, children }) {
// Kicks off the chunk download before the user clicks
const prefetch = () => import('./HeavyDashboard');
return (
<a
href={to}
onMouseEnter={prefetch} // prefetch on hover
onFocus={prefetch} // prefetch on keyboard focus
>
{children}
</a>
);
}By the time the user's finger lifts off the mouse button, the chunk is likely already cached. I've seen this pattern cut perceived navigation time from 800ms to under 100ms on slower connections.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.