Skip to main content

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
  • webpackPrefetch lets 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.

javascript
// 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:

javascript
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:

javascript
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:

javascript
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 it
  • webpackPreload: true - the browser fetches it in parallel with the parent chunk
javascript
// 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.

jsx
// 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; manualChunks option for vendor extraction
  • React Router v6 - combine with React.lazy for route-level splitting
  • Vue - defineAsyncComponent(() => import('./Component.vue')) works the same way
  • Angular - lazy-loaded modules via the router's loadChildren property

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

javascript
// 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

javascript
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

javascript
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?