Code splitting is the practice of breaking a JavaScript bundle into smaller chunks that are loaded on demand instead of shipping everything in one large file. Without it, every user downloads your entire app upfront — even code for routes or features they never visit.
// Without splitting — one giant bundle (bad for initial load)
import HeavyDashboard from './HeavyDashboard'
import ReportsPDF from './ReportsPDF'
// With splitting — each module loads only when needed
const HeavyDashboard = React.lazy(() => import('./HeavyDashboard'))
const ReportsPDF = React.lazy(() => import('./ReportsPDF'))
The browser only fetches a chunk when the user triggers the code path that
needs it, dramatically reducing Time to Interactive (TTI) and
First Contentful Paint (FCP) for the initial page. Tools like Webpack,
Vite, and Rollup split bundles automatically at every import() call.
Benefits include faster initial load, smaller parse/compile work on the main thread, and better cache granularity — a chunk for a rarely-changed library won't bust the cache when your app logic changes.
Rule of thumb: Split at route boundaries first — that alone cuts most apps' initial bundle by 30–60 % without any layout complexity.
React.lazy accepts a factory function that returns a promise of a
module with a default export that is a React component. Under the hood
it wraps the dynamic import() expression, which is a native browser/bundler
feature that triggers on-demand chunk loading.
import React, { lazy, Suspense } from 'react'
// dynamic import() returns Promise<module>
// React.lazy unwraps the default export for you
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
function App() {
return (
<Suspense fallback={<div>Loading settings…</div>}>
<SettingsPage />
</Suspense>
)
}
When React first tries to render <SettingsPage />, it kicks off the
import() call. While the network request is in flight, React throws a
promise (internally), and the nearest <Suspense> boundary catches it
and renders the fallback. Once the chunk resolves, React re-renders with
the real component.
The bundle tool (Webpack/Vite) sees the import() and automatically creates
a separate output chunk at build time.
Rule of thumb: Always wrap the factory in an arrow function —
lazy(import('./Foo')) executes immediately and defeats lazy loading.
React.lazy components are asynchronous — they need to fetch their
chunk from the network before they can render. React's Suspense mechanism
lets a component signal "I'm not ready yet" by throwing a promise. Without a
<Suspense> ancestor to catch that signal, React has nowhere to show a
loading state and will throw an unhandled error instead.
// ❌ Missing Suspense — throws at runtime
function App() {
return <LazyChart /> // React.lazy component with no Suspense ancestor
}
// ✅ Suspense boundary catches the pending state
function App() {
return (
<Suspense fallback={<Spinner />}>
<LazyChart />
</Suspense>
)
}
You can place the boundary anywhere above the lazy component in the tree. Nesting multiple boundaries lets you control fallback granularity — a top-level boundary shows a full-page spinner while inner boundaries show smaller skeletons for individual sections.
Rule of thumb: Put <Suspense> as close to the lazy component as
makes sense for your UX — the tighter the boundary, the less UI is
replaced by the fallback.
Route-based splitting loads an entire page component only when the user navigates to that route. It is the highest-leverage split because each route is a natural isolation boundary and users rarely visit every route in one session.
Component-based splitting defers loading of a single heavy widget (e.g., a rich-text editor, chart library, or PDF viewer) until it is actually rendered on the page, regardless of route.
// Route-based — whole pages are lazy
const Home = lazy(() => import('./pages/Home'))
const Dashboard = lazy(() => import('./pages/Dashboard'))
<Routes>
<Route path="/" element={
<Suspense fallback={<PageSpinner />}><Home /></Suspense>
} />
<Route path="/dashboard" element={
<Suspense fallback={<PageSpinner />}><Dashboard /></Suspense>
} />
</Routes>
// Component-based — a heavy widget on one page
const RichEditor = lazy(() => import('./RichEditor'))
function PostEditor({ showEditor }) {
return showEditor
? <Suspense fallback={<EditorSkeleton />}><RichEditor /></Suspense>
: <SimplePlaceholder />
}
Route-based is always the first step; component-based is used when a single route still bundles too much.
Rule of thumb: Start with route-based splitting; add component-based splitting only when bundle analysis identifies a specific heavy dependency on a single route.
The fallback prop accepts any renderable React node — a string, JSX,
a spinner component, or a skeleton layout. It renders while the lazy chunk
is loading and is replaced by the real content once the import resolves.
// Simple text fallback
<Suspense fallback="Loading…">
<LazyComponent />
</Suspense>
// Spinner component
<Suspense fallback={<Spinner size="lg" />}>
<LazyComponent />
</Suspense>
// Skeleton that matches the real layout (best UX)
<Suspense fallback={<DashboardSkeleton />}>
<LazyDashboard />
</Suspense>
// null — renders nothing while loading (use with care)
<Suspense fallback={null}>
<LazyModal />
</Suspense>
A skeleton that mirrors the real component's layout reduces Cumulative Layout Shift (CLS) because the page structure does not jump when the real content appears. Avoid heavy fallbacks that are themselves expensive to render.
The fallback is only shown on the first load of a chunk; subsequent renders use the cached module and skip the fallback entirely.
Rule of thumb: Match the fallback's dimensions to the real component to
prevent layout shift — even a simple fixed-height <div> is better than
nothing.
React.lazy requires the resolved module to have a default export. For
components exported as named exports, wrap the import in an intermediate
re-export or inline re-map inside the factory function.
// components/Charts.tsx — named export
export function BarChart() { /* … */ }
export function LineChart() { /* … */ }
// Option 1: inline re-map in the factory
const BarChart = lazy(() =>
import('./components/Charts').then(mod => ({ default: mod.BarChart }))
)
// Option 2: create a thin re-export file (Charts.BarChart.ts)
// Charts.BarChart.ts
export { BarChart as default } from './Charts'
// then lazy-import that file normally
const BarChart = lazy(() => import('./components/Charts.BarChart'))
Both approaches satisfy React.lazy's contract of a promise that resolves to
{ default: Component }. The inline .then() approach is convenient for
one-offs; a separate re-export file is cleaner when you lazy-load the same
named export in multiple places.
Rule of thumb: Prefer Option 1 for a single use case and Option 2 when
the same named export is lazy-loaded in more than two places — it avoids
repetitive .then() boilerplate.
If the network request for a lazy chunk fails (e.g., 404, offline), the
thrown promise rejects and React propagates an error up the tree.
A <Suspense> boundary does not catch errors — you need a separate
Error Boundary component wrapping the lazy component.
import { Component } from 'react'
class ChunkErrorBoundary extends Component {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return (
<button onClick={() => this.setState({ hasError: false })}>
Retry
</button>
)
}
return this.props.children
}
}
// Wrap lazy component with both boundaries
<ChunkErrorBoundary>
<Suspense fallback={<Spinner />}>
<LazySettings />
</Suspense>
</ChunkErrorBoundary>
The error boundary must sit outside <Suspense> so it can catch chunk
fetch errors after Suspense has already caught the pending promise. Libraries
like react-error-boundary provide a hook-friendly API for this pattern.
Rule of thumb: Always pair React.lazy with an error boundary in
production — chunk load failures are real and users deserve a recovery path,
not a blank screen.
Because React.lazy wraps a factory function, you can trigger the
import manually before React needs to render the component. The browser
fetches and caches the chunk, so by the time the component mounts, the
module is already available and the Suspense fallback is skipped.
const LazyDashboard = lazy(() => import('./Dashboard'))
// Preload on hover — user shows intent before clicking
function NavLink() {
const preload = () => import('./Dashboard') // same import() as lazy
return (
<a
href="/dashboard"
onMouseEnter={preload} // starts fetch on hover
onFocus={preload} // keyboard nav support
>
Dashboard
</a>
)
}
// Or preload after idle time with requestIdleCallback
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(() => import('./Dashboard'))
}
Call the same dynamic import() path used inside lazy() — the bundler
and browser cache the result by URL, so the second call (from React.lazy)
is instant.
Rule of thumb: Preload on mouseenter or focus for navigation links —
you get a ~100–300 ms head start for free, and most users never notice the
chunk was loaded proactively.
Webpack and Vite both support magic comments inside import() to set
the output filename and other chunk behavior. The most common is
/* webpackChunkName: "…" */ (Webpack) or /* @vite-ignore */ (Vite, for
suppressing warnings).
// Webpack — names the output chunk "dashboard~chunk.js"
const Dashboard = lazy(
() => import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')
)
// Webpack prefetch hint — browser fetches in idle time after initial load
const Reports = lazy(
() => import(
/* webpackChunkName: "reports" */
/* webpackPrefetch: true */
'./pages/Reports'
)
)
// Webpack preload hint — fetches in parallel with parent chunk
const HeroChart = lazy(
() => import(
/* webpackChunkName: "hero-chart" */
/* webpackPreload: true */
'./components/HeroChart'
)
)
Named chunks are easier to identify in bundle reports and give cache-friendly
filenames. webpackPrefetch emits a <link rel="prefetch"> tag;
webpackPreload emits <link rel="preload"> — use preload sparingly as it
competes with critical resources.
Rule of thumb: Name every lazy chunk — anonymous hashes like 3.js
are useless in production error logs and bundle analysis tools.
Without splitting, the browser must download, parse, and compile the entire JavaScript bundle before any interactive content appears. Code splitting reduces all three phases for the initial visit by shipping only the code needed for the landing route.
Before splitting:
Bundle: 1.2 MB → parse: 800 ms → TTI: 3.2 s
After route-based splitting:
Initial chunk: 280 KB → parse: 180 ms → TTI: 1.1 s
/dashboard chunk: 420 KB ← loaded only when user navigates there
/reports chunk: 500 KB ← loaded only when user navigates there
// Each lazy() call becomes a separate network request only on demand
const Dashboard = lazy(() => import(/* webpackChunkName: "dashboard" */ './Dashboard'))
const Reports = lazy(() => import(/* webpackChunkName: "reports" */ './Reports'))
// First paint only fetches the initial chunk
// Dashboard and Reports chunks are fetched on navigation
The savings compound with caching: once a user has visited /dashboard,
the chunk is cached and subsequent navigations are instant.
Rule of thumb: Measure before and after with npm run build -- --report
or Vite's --reporter flag — aim to keep your initial chunk under 200 KB
gzipped.
React.lazy is not supported in SSR with React 17 and earlier — it only
works in the browser. On the server, calling a lazy component throws because
there is no Suspense-compatible server renderer to handle the dynamic import.
React 18's renderToPipeableStream and renderToReadableStream do support
Suspense on the server, but React.lazy itself still requires the module to
be available synchronously during SSR unless you use a framework that handles
it.
// For SSR, use framework-native lazy loading instead:
// Next.js — next/dynamic handles SSR by default
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false, // skip server render entirely for browser-only widgets
})
// Remix — uses route-level splitting natively via its file-system router
// No extra config needed — every route file is a split point
For framework-agnostic SSR, @loadable/component is the standard solution —
it serializes which chunks were used on the server and preloads them on the
client to avoid hydration mismatches.
Rule of thumb: In SSR apps, reach for your framework's dynamic import
helper (Next.js dynamic, Remix routes, Nuxt defineAsyncComponent) rather
than bare React.lazy.
Yes. A single <Suspense> boundary handles all lazy descendants — it
shows the fallback until every lazy child in its subtree has resolved.
This is fine when you want a single loading state for a group of components
that logically appear together.
const Sidebar = lazy(() => import('./Sidebar'))
const MainFeed = lazy(() => import('./MainFeed'))
const Widgets = lazy(() => import('./Widgets'))
// One boundary — fallback shows until ALL three chunks are ready
<Suspense fallback={<PageSkeleton />}>
<Sidebar />
<MainFeed />
<Widgets />
</Suspense>
// Nested boundaries — each section shows its own fallback independently
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<MainFeed />
<Widgets /> {/* Widgets shares MainFeed's boundary */}
</Suspense>
Multiple lazy components under one boundary also means all their network requests fire in parallel — React does not wait for one to finish before starting the next.
Rule of thumb: Share a single boundary when the components form one visual unit; nest separate boundaries when different sections can independently become interactive.
React.lazy is powerful but has several constraints worth knowing for
interviews:
// 1. Default exports only — named exports need re-mapping
const Foo = lazy(() => import('./Foo')) // ✅
const Bar = lazy(() => import('./Bar').then(m => ({ default: m.Bar }))) // ✅ workaround
// 2. Must be called at module level, not inside render
function BadParent() {
// ❌ Creates a new lazy reference every render — loses state and re-fetches
const Lazy = lazy(() => import('./Child'))
return <Lazy />
}
// 3. No server-side rendering support (React 17 and earlier)
// Use next/dynamic or @loadable/component in SSR apps
// 4. Requires a Suspense boundary — no built-in fallback
// 5. No built-in error handling — needs an Error Boundary
Additional limitations: you cannot lazy-load hooks or context providers
(only renderable components), and React.lazy does not support passing
options like timeout or retry logic — those require custom wrappers.
Rule of thumb: Declare lazy() at module scope, not inside component
bodies — creating a new lazy reference on every render causes the chunk to
re-fetch and the component to remount, destroying local state.
Third-party packages imported at the top of a file are bundled into whichever chunk imports them. To split them out, import them dynamically inside the component or effect that needs them instead of at the top level.
// ❌ Static import — lands in the initial bundle even if rarely used
import { Chart } from 'chart.js'
// ✅ Dynamic import — chunk created only for routes that use Chart
function SalesChart({ data }) {
const [ChartLib, setChartLib] = useState(null)
useEffect(() => {
import('chart.js').then(mod => setChartLib(() => mod.Chart))
}, [])
if (!ChartLib) return <ChartSkeleton />
return <ChartLib data={data} />
}
// ✅ Better: wrap with React.lazy for Suspense support
const PDFViewer = lazy(() =>
import('react-pdf').then(mod => ({ default: mod.Document }))
)
// ✅ Webpack vendor chunk — group stable libs into a long-cached chunk
// In webpack.config.js:
// optimization.splitChunks.cacheGroups.vendors: { test: /node_modules/ }
For libraries like Moment.js or lodash, also check if tree-shaking and lighter alternatives (date-fns, lodash-es) eliminate the need to split at all.
Rule of thumb: Check bundle-analyzer output first — if a library
accounts for over 50 KB gzipped in your initial bundle and is not needed on
the landing page, it is a prime split candidate.
Three complementary tools cover the full picture: the bundler's built-in stats, a visual analyzer, and a real-browser network trace.
# Webpack — generate stats.json then open in webpack-bundle-analyzer
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json
# Vite — built-in rollup visualizer plugin
# vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
plugins: [react(), visualizer({ open: true, gzipSize: true })]
# CRA — source-map-explorer
npm run build
npx source-map-explorer 'build/static/js/*.js'
// After splitting, compare chunk sizes in the build output:
// dist/assets/index-3f2a.js → 87 KB (initial)
// dist/assets/dashboard-9c1b.js → 142 KB (on demand)
// dist/assets/reports-4d7e.js → 198 KB (on demand)
// Initial savings: 340 KB removed from first load
Also check the Network tab in DevTools with throttling enabled — confirm that on-demand chunks appear as separate requests timed to navigation, not on initial page load.
Rule of thumb: Run bundle analysis in CI and set a size budget (e.g.,
bundlesize or Vite's build.chunkSizeWarningLimit) — that way regressions
are caught before they reach production.
More Rendering and Performance interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.