Overview
Code splitting is the practice of breaking a JavaScript application into smaller chunks that can be loaded only when needed. Lazy loading is one common way to trigger those chunks on demand. Bundle analysis is the process of inspecting what actually ended up in the production bundle and why. Route-level loading applies these ideas at navigation boundaries so each route can load its code, data, and pending UI deliberately.
This matters because React applications often grow by adding routes, forms, charts, editors, date libraries, design systems, validation libraries, and API clients. If everything ships in the initial bundle, first load gets slower, parsing and execution cost increases, and users pay for features they may never open.
In production apps, code splitting is commonly used for admin sections, dashboards, report builders, markdown editors, rich text editors, route modules, modals, maps, charts, and rarely used workflows. It should be guided by real measurements, not by splitting every file just because dynamic import exists.
For interviews, this topic is important because it tests whether a candidate understands the difference between network size, parse/execute cost, loading UX, route boundaries, caching, and practical trade-offs. A strong answer balances performance with maintainability.
Core Concepts
Initial Bundle
The initial bundle is the JavaScript needed before the app can render the first meaningful screen. It usually includes the app shell, router, core layout, shared UI primitives, auth bootstrap, critical route code, and vendor dependencies required immediately.
Large initial bundles hurt because the browser must:
- Download the files.
- Parse JavaScript.
- Compile JavaScript.
- Execute module initialization.
- Hydrate or render the UI.
Code splitting reduces what is needed up front by moving non-critical code into async chunks.
Dynamic Import
Dynamic import() is the JavaScript mechanism bundlers use to create async chunks.
async function openReportBuilder() {
const module = await import("./ReportBuilder");
return module.ReportBuilder;
}
Static imports are loaded with the main module graph:
import { ReportBuilder } from "./ReportBuilder";
Dynamic imports are loaded when code reaches the import call. This is the foundation for many lazy loading patterns.
React.lazy
React.lazy lets a component's code load only when that component is first rendered.
import { lazy, Suspense } from "react";
const MarkdownPreview = lazy(() => import("./MarkdownPreview"));
export function Editor() {
return (
<Suspense fallback={<p>Loading preview...</p>}>
<MarkdownPreview />
</Suspense>
);
}
Important rules:
- Declare lazy components at module scope, not inside another component.
- The imported module must provide a default export for
React.lazy. - Wrap lazy components in
Suspense. - Use an error boundary for failed chunk loads.
Bad:
function Editor() {
const MarkdownPreview = lazy(() => import("./MarkdownPreview"));
return <MarkdownPreview />;
}
Declaring the lazy component inside render can reset state and recreate the component type.
Suspense
Suspense shows fallback UI while a child tree is waiting for something, such as a lazy component chunk.
<Suspense fallback={<RouteSkeleton />}>
<SettingsPage />
</Suspense>
Good Suspense fallbacks:
- Match the space the content will occupy.
- Avoid layout jumps.
- Avoid replacing the whole app shell when only one panel is loading.
- Use skeletons for route content.
- Use small spinners for small regions.
Poor Suspense fallbacks:
- Full-page blank states for every small chunk.
- Loading text with no context.
- Nested boundaries that flash too often.
- Fallbacks that hide already-loaded stable layout.
Suspense improves loading UX only when boundaries are placed thoughtfully.
Error Boundaries for Lazy Chunks
Lazy chunks can fail to load because of network errors, deployment version mismatch, ad blockers, stale service workers, or corrupted caches.
Wrap lazy regions with error boundaries:
<ErrorBoundary fallback={<ChunkLoadError />}>
<Suspense fallback={<SettingsSkeleton />}>
<SettingsPage />
</Suspense>
</ErrorBoundary>
Good chunk error UX should offer:
- Retry.
- Refresh page.
- Clear message.
- Support code if useful.
Do not assume lazy loading only has a loading path. It also has a failure path.
Route-Level Code Splitting
Routes are natural code-splitting boundaries because users navigate to one route at a time.
With React Router route objects, route modules can be lazy:
const router = createBrowserRouter([
{
path: "/settings",
lazy: async () => {
const module = await import("./routes/settings");
return {
Component: module.SettingsRoute,
loader: module.loader,
ErrorBoundary: module.ErrorBoundary,
};
},
},
]);
This lets route code load when the route is needed. For many apps, route-level splitting gives better results than splitting small shared components.
Route-Level Data Loading
Route-level loading is not only about code. It is also about data and pending UI.
Common responsibilities:
- Load route code.
- Load route data.
- Show pending navigation UI.
- Handle loader errors.
- Avoid duplicate fetches.
- Preserve app shell while route content loads.
Example with pending navigation:
function AppShell() {
const navigation = useNavigation();
const isNavigating = navigation.state !== "idle";
return (
<>
{isNavigating ? <TopProgressBar /> : null}
<Outlet />
</>
);
}
Route-level pending UI should tell users that navigation is happening without destroying useful context.
Bundle Analysis
Bundle analysis answers: what is in the bundle, how big is it, and why is it there?
Useful questions:
- Which dependencies dominate the bundle?
- Are multiple versions of a library included?
- Is a heavy library imported by the initial route?
- Are dynamic imports creating useful chunks?
- Did a barrel file accidentally pull in too much?
- Are locale files, icons, or editor plugins included unnecessarily?
- Are dev-only dependencies leaking into production?
Bundle analysis should be part of performance work because intuition about bundle size is often wrong.
Bundle Size Metrics
Different sizes mean different things:
- Raw size: file size before compression.
- Minified size: after removing whitespace and shortening code.
- Gzip or Brotli size: transfer size over network.
- Parsed size: JavaScript the browser must parse.
- Execution cost: work done when modules initialize.
A small compressed file can still have expensive parse or execution cost. A charting library, date library, or editor can be costly even if transfer size looks acceptable.
Vendor Chunks
Bundlers often separate application code from vendor dependencies. Manual chunking can help caching and loading strategy, but it can also make performance worse if done blindly.
Useful vendor chunk strategy:
- Keep stable, commonly used dependencies cacheable.
- Split rarely used heavy dependencies from the initial route.
- Avoid one massive vendor chunk that every route must download.
- Avoid too many tiny chunks that create request overhead.
Example Vite/Rollup configuration shape:
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
charts: ["recharts"],
markdown: ["react-markdown"],
},
},
},
},
});
Manual chunks should follow measured usage patterns, not guesswork.
Lazy Loading Heavy Features
Good lazy loading candidates:
- Rich text editors.
- Markdown previewers.
- Charting dashboards.
- Map components.
- Admin-only routes.
- Report builders.
- Large schema-driven forms.
- Rarely used modals.
- Payment provider SDKs that are not needed immediately.
Poor candidates:
- Tiny components used on the first screen.
- Core layout components.
- Shared UI primitives used everywhere.
- Components whose loading fallback is more expensive than the component.
Lazy loading has overhead. Split where it meaningfully improves the user path.
Prefetching and Preloading
Lazy loading can create a delay on first use. Prefetching can reduce that delay by starting the load before the user actually navigates.
Common prefetch triggers:
- Link hover.
- Link visible in viewport.
- User intent signal.
- After initial route becomes idle.
- Route likely to be visited next.
Example:
function preloadSettingsRoute() {
void import("./routes/settings");
}
<Link to="/settings" onMouseEnter={preloadSettingsRoute}>
Settings
</Link>
Prefetching should be selective. Eagerly prefetching every route can undo the benefit of code splitting.
Avoiding Waterfalls
Lazy loading can accidentally create waterfalls:
- Load route chunk.
- Then route component starts data request.
- Then component lazy-loads a heavy child.
- Then child starts another request.
Better patterns:
- Start route data in loaders.
- Lazy-load route module and important data in parallel where the router supports it.
- Preload likely child chunks.
- Move fetches out of deeply nested effects when route data is known.
The goal is not only smaller chunks. It is faster user-perceived loading.
Route Boundaries and UX
Route-level splitting should preserve layout stability.
Good pattern:
- Keep app shell loaded.
- Show route skeleton in the outlet area.
- Keep navigation visible.
- Show route-specific error boundary on failure.
Bad pattern:
- Blank the entire screen for every route change.
- Lose navigation and user context.
- Show unrelated spinners in multiple regions.
- Hide already loaded data during background route revalidation.
Users should know where they are, where they are going, and what is loading.
Common Mistakes
Common mistakes include:
- Declaring
lazycomponents inside render. - Splitting tiny components while leaving huge libraries in the initial bundle.
- Not adding error boundaries for failed chunks.
- Showing full-page spinners for every lazy section.
- Creating too many tiny chunks.
- Creating one huge vendor chunk needed by every route.
- Lazy loading code but still fetching data in nested effects, causing waterfalls.
- Not measuring before and after.
- Importing heavy dependencies through broad barrel files.
- Ignoring parse and execution cost.
Best Practices
Best practices include:
- Start with route-level splitting.
- Lazy-load heavy, rare, or role-specific features.
- Keep critical app shell code eager.
- Use
Suspenseboundaries that preserve context. - Add error boundaries around lazy routes or chunks.
- Analyze production bundles regularly.
- Track initial JavaScript, route chunks, and duplicate dependencies.
- Prefer measured manual chunking over guesswork.
- Prefetch likely next routes selectively.
- Avoid waterfalls by coordinating code and data loading.