Bundle Optimization & Code Splitting
The average website ships 500KB+ of JavaScript. On a mid-range phone over 3G, that's 15+ seconds before interactive. Bundle optimization isn't about saving bytes â it's about respecting your users' time and hardware.
The Cost of JavaScript
JavaScript is uniquely expensive compared to other resources of equal size:
200KB image: Download â Decode â Paint (GPU-assisted, fast)
200KB JS: Download â Parse â Compile â Execute (main thread, blocking)A 200KB JavaScript bundle takes 3-4x longer to process than a 200KB image on the same device.
Code Splitting Strategies
Route-Based Splitting (Highest Impact)
Every route becomes a separate chunk:
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Products = lazy(() => import('./pages/Products'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/products" element={<Products />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}Next.js App Router does this automatically â each page.tsx is a separate chunk.
Feature-Based Splitting
Heavy features that aren't always used:
const RichTextEditor = lazy(() => import('./components/RichTextEditor'));
const ChartDashboard = lazy(() => import('./components/ChartDashboard'));
const PDFExporter = lazy(() => import('./components/PDFExporter'));
function PostEditor() {
return (
<Suspense fallback={<EditorSkeleton />}>
{mode === 'edit' && <RichTextEditor />}
</Suspense>
);
}Conditional Splitting (Role/Feature Flag)
async function loadAdminTools() {
if (!user.isAdmin) return null;
const { AdminPanel } = await import('./admin/AdminPanel');
return AdminPanel;
}Prefetch on Hover/Focus
function NavLink({ to, children }) {
const prefetch = useCallback(() => {
import(`./pages/${to}`);
}, [to]);
return (
<Link to={to} onMouseEnter={prefetch} onFocus={prefetch}>
{children}
</Link>
);
}By the time the user clicks, the chunk is likely cached.
Tree Shaking
Tree shaking removes dead code â exports that are never imported. It requires ES modules (static import/export):
// math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function complexAnalysis(data) { /* 2000 lines */ }
// app.js
import { add } from './math.js';
// subtract and complexAnalysis are tree-shaken outWhy Tree Shaking Fails
Side effects:
// polyfill.js â has side effects (modifies globals)
import './polyfill.js'; // Bundler can't remove this
// package.json â tell bundler what's safe to shake
{
"sideEffects": false
}
// or specify files with side effects:
{
"sideEffects": ["*.css", "./src/polyfills.js"]
}Barrel files:
// components/index.js (barrel)
export { Button } from './Button';
export { Modal } from './Modal'; // 15KB
export { DataTable } from './DataTable'; // 80KB
export { ChartWidget } from './Chart'; // 120KB
// Importing Button may pull in ALL of them if bundler
// can't trace through the barrel
import { Button } from './components';
// Safer: direct imports
import { Button } from './components/Button';CommonJS modules:
// â Not tree-shakeable â dynamic, runtime resolution
const { pick } = require('lodash');
// â
Tree-shakeable â static, compile-time analysis
import { pick } from 'lodash-es';
// â
Direct import (always works)
import pick from 'lodash/pick';Import Cost Awareness
| Library | Full Import | Optimized Import | Savings |
|---|---|---|---|
lodash | ~72KB | lodash-es or direct | ~90% |
date-fns | ~80KB | Direct function imports | ~95% |
moment | ~67KB | Use dayjs (2KB) | ~97% |
rxjs | ~47KB | rxjs/operators | ~80% |
// â Imports entire library
import { format } from 'date-fns'; // Pulls in more than needed
// â
Direct import
import format from 'date-fns/format'; // Just the format functionCompression
Original JavaScript: 500KB
Minified (Terser): 200KB (60% reduction)
Gzip compressed: 55KB (89% total reduction)
Brotli compressed: 45KB (91% total reduction)Brotli is 15-25% smaller than Gzip. Both are supported by all modern browsers over HTTPS.
Enabling Brotli
# Nginx
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/javascript application/json;Bundle Analysis
You can't optimize what you can't see:
# Next.js
ANALYZE=true npx next build
# Webpack
npx webpack-bundle-analyzer stats.json
# Vite
npx vite-bundle-visualizerWhat to Look For
- Duplicate dependencies â Two versions of the same library
- Surprisingly large modules â A "small utility" pulling in 100KB
- Client-only code in server bundles (and vice versa)
- Unused code â Large libraries where you use 5% of the exports
- Uncompressed comparison â Transfer size vs parsed size
Dynamic Import Patterns
Library-On-Demand
async function handleExport() {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF();
doc.text('Report', 10, 10);
doc.save('report.pdf');
}
async function showChart(data) {
const { Chart } = await import('chart.js/auto');
new Chart(canvas, { type: 'line', data });
}Feature Detection Loading
async function initializeAnalytics() {
if (window.location.hostname === 'localhost') return;
const { initGA } = await import('./analytics/google');
const { initSentry } = await import('./analytics/sentry');
initGA(config.gaId);
initSentry(config.sentryDsn);
}PRPL Pattern
PRPL is a loading strategy that maximizes performance on constrained devices:
| Letter | Step | Implementation |
|---|---|---|
| Push | Push critical resources for initial route | <link rel="preload">, HTTP/2 push |
| Render | Render initial route ASAP | Inline critical CSS, SSR/SSG |
| Pre-cache | Pre-cache remaining routes | Service Worker caches other route chunks |
| Lazy-load | Lazy-load remaining routes on demand | import() on navigation |
First Load:
Push: hero.css, app.js, route-home.js (preload)
Render: Show home page immediately
Background:
Pre-cache: route-about.js, route-products.js (Service Worker)
On Navigation:
Lazy-load: Already cached â instant transitionList Virtualization
For long lists (1000+ items), only render what's visible in the viewport:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: virtualItem.start,
height: virtualItem.size,
width: '100%',
}}
>
{items[virtualItem.index].name}
</div>
))}
</div>
</div>
);
}Impact: 10,000 items â 20-30 DOM nodes. Eliminates layout thrashing and memory bloat.
Import-On-Visibility / Import-On-Interaction
Load code only when the user can see or interacts with the component:
// Import on visibility (uses IntersectionObserver)
function LazyChart({ data }) {
const [Chart, setChart] = useState(null);
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
import('chart.js/auto').then(m => setChart(() => m.Chart));
observer.disconnect();
}
});
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return <canvas ref={ref} />;
}
// Import on interaction
function CommentSection() {
const [Editor, setEditor] = useState(null);
const handleFocus = () => {
if (!Editor) {
import('./RichTextEditor').then(m => setEditor(() => m.default));
}
};
return Editor
? <Editor />
: <textarea onFocus={handleFocus} placeholder="Write a comment..." />;
}Performance Budget Enforcement
{
"budgets": [
{
"resourceType": "script",
"budget": 200,
"unit": "kb"
},
{
"resourceType": "total",
"budget": 500,
"unit": "kb"
},
{
"metric": "first-contentful-paint",
"budget": 1500,
"unit": "ms"
}
]
}Integrate into CI:
- Bundle size check on every PR
- Lighthouse CI with performance budgets
- Alert when any chunk exceeds the threshold
The Optimization Checklist
- Analyze â Run bundle analyzer, identify largest chunks
- Split routes â Each route is a separate chunk
- Audit imports â Replace heavy libraries, use direct imports
- Lazy-load features â Charts, editors, PDFs, maps â on demand
- Check barrel files â Ensure they don't prevent tree shaking
- Enable compression â Brotli > Gzip
- Set budgets â Fail CI on violations
- Monitor â Track bundle size over time, catch creep early
Interview Signal
Senior candidates demonstrate:
- Cost awareness â JS costs more than equal-size images to process
- Strategy hierarchy â Route splitting first, then feature splitting, then micro-optimization
- Tree shaking knowledge â Why ESM is required, what defeats tree shaking
- Tooling fluency â Bundle analysis, Lighthouse CI, performance budgets
- Measurement discipline â Analyze before optimizing, monitor after shipping