Day 36: Web Performance: Code Splitting, Lazy Loading & Bundle Analysis
Reduce the JS a user must download before your app becomes interactive by finding and cutting what does not need to ship on day one.
Study
Concepts
Code splitting: ship less, on demand
A bundler (Webpack/Turbopack/Rollup) can split one large JS bundle into many smaller chunks, loaded on demand instead of all upfront. `React.lazy(() => import('./Modal'))` combined with `<Suspense>` is the standard React idiom: the Modal's code is not downloaded until the first time it is actually rendered, shrinking the INITIAL bundle a user must fetch and parse before the app becomes interactive.
Route-based splitting (one chunk per page) is the highest-leverage place to start — a user visiting the homepage should not download the checkout flow's code. Next.js and most modern routers do this automatically per route; for a plain SPA it is a manual `React.lazy` per route component.
Bundle analysis: find out what you are actually shipping
A bundle analyzer (e.g. `webpack-bundle-analyzer`, or Next.js's built-in `@next/bundle-analyzer`) renders a treemap of exactly what is inside your production bundle, sized by byte weight — this is how you discover a 300KB moment library imported for one date-format call, or a duplicated dependency pulled in by two different packages at two different versions.
Two metrics matter for the initial-load story: Total Blocking Time / Time To Interactive (how long until the page responds to input) is driven mostly by JS parse+execute time, so shipping less JS upfront directly improves it; Largest Contentful Paint is driven more by the biggest visible element (often an image) and critical CSS/font loading, which code splitting does not directly fix.
See It
Visualizations
Visualization
From one bundle to route-based chunks
everything, downloaded before ANY interaction
home.chunk.js, checkout.chunk.js, ...
Modal.chunk.js loads only when opened
faster Time To Interactive
Build It
Code Examples
Lazy-loading a heavy component with Suspense
import { lazy, Suspense, useState } from 'react';
// Not downloaded until this module is actually imported at runtime
const ReportChart = lazy(() => import('./ReportChart'));
function Dashboard() {
const [showReport, setShowReport] = useState(false);
return (
<>
<button onClick={() => setShowReport(true)}>View Report</button>
{showReport && (
<Suspense fallback={<Spinner />}>
<ReportChart /> {/* triggers the chunk download on first render */}
</Suspense>
)}
</>
);
}Avoiding a heavy import for one small function
// Costly: pulls in the ENTIRE library (often 200KB+) for one function
import moment from 'moment';
const formatted = moment(date).format('YYYY-MM-DD');
// Better: a focused, tree-shakeable alternative, or a native API
const formatted = new Intl.DateTimeFormat('en-CA').format(date); // zero dependencies
// If you truly need a library, import only what you use from a
// modular one (date-fns) so bundlers can tree-shake the rest away
import { format } from 'date-fns';
const formatted2 = format(date, 'yyyy-MM-dd');Remember
Key Takeaways
- Code splitting defers loading JS until it is actually needed — route-based splitting is the highest-leverage starting point.
- React.lazy + Suspense is the standard idiom for component-level splitting on interaction (modals, tabs, heavy widgets).
- A bundle analyzer treemap is how you FIND bloat — never guess which dependency is heavy, measure it.
- Time To Interactive is mostly about how much JS you ship and parse; Largest Contentful Paint is mostly about images/fonts/CSS.
- Prefer native APIs or modular, tree-shakeable libraries over large all-in-one dependencies for small utility needs.
Do It
Practice
- 1Run a bundle analyzer on a real project and identify the single largest dependency by byte weight.
- 2Convert one route or modal in a project to React.lazy + Suspense and measure the initial bundle size before/after.
- 3Replace one moment.js (or similarly heavy) usage with a native Intl API call and confirm the bundle shrinks.