Web Performance: Lazy Loading and Bundle Optimization
Every kilobyte you ship to users costs time, and time costs users. Google's research shows that 53% of mobile users abandon sites that take longer than 3 seconds to load. Yet the median JavaScript...
Key Insights
- Code splitting and lazy loading can reduce initial bundle size by 50-70%, directly improving Time to Interactive and reducing bounce rates
- Route-based splitting should be your default, but component-level lazy loading of heavy dependencies (charts, editors, modals) delivers the biggest wins
- Performance budgets enforced in CI/CD prevent regression—set max bundle sizes and fail builds that exceed thresholds
The Performance Problem
Every kilobyte you ship to users costs time, and time costs users. Google’s research shows that 53% of mobile users abandon sites that take longer than 3 seconds to load. Yet the median JavaScript bundle size continues to grow, now exceeding 400KB on mobile according to HTTP Archive data.
The problem compounds: larger bundles mean longer download times, but also longer parse and compile times. JavaScript is particularly expensive—the browser must download, parse, compile, and execute it before rendering interactive content. A 400KB JavaScript bundle can take 2-3 seconds just to parse on mid-range mobile devices.
Bundle optimization and lazy loading address this directly. Instead of shipping everything upfront, you deliver only what users need immediately, then load additional code on demand. This reduces Time to Interactive (TTI) from seconds to milliseconds.
Here’s what unoptimized bundles look like versus optimized ones:
# Before optimization
main.bundle.js 847 KB
vendor.bundle.js 1.2 MB
Total Initial Load: 2.0 MB
# After optimization
main.bundle.js 124 KB
vendor.bundle.js 287 KB
route-dashboard.js 156 KB (lazy)
route-analytics.js 98 KB (lazy)
chart-lib.js 203 KB (lazy)
Total Initial Load: 411 KB (80% reduction)
Understanding Bundle Optimization Fundamentals
Modern bundlers provide three core optimization techniques: tree shaking, code splitting, and minification.
Tree shaking eliminates dead code by analyzing ES module imports and removing unused exports. It only works with ES modules (import/export), not CommonJS (require).
Code splitting breaks your application into smaller chunks loaded on demand. Instead of one massive bundle, you create multiple smaller bundles loaded as needed.
Minification removes whitespace, shortens variable names, and applies other transformations to reduce file size without changing functionality.
Here’s a production-ready Webpack configuration:
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
mode: 'production',
optimization: {
usedExports: true, // Tree shaking
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10
},
common: {
minChunks: 2,
priority: 5,
reuseExistingChunk: true
}
}
}
},
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: false,
reportFilename: 'bundle-report.html'
})
]
};
Enable tree shaking in your package.json:
{
"name": "my-app",
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.js"
]
}
The sideEffects array tells bundlers which files have side effects and shouldn’t be tree-shaken. Most code has no side effects—mark it as such.
Implementing Route-Based Code Splitting
Route-based splitting is the lowest-hanging fruit. Users visiting /dashboard don’t need code for /settings. Split at route boundaries to load only necessary code.
React makes this straightforward with React.lazy() and Suspense:
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Lazy load route components
const Dashboard = lazy(() => import('./routes/Dashboard'));
const Analytics = lazy(() => import('./routes/Analytics'));
const Settings = lazy(() => import('./routes/Settings'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
For vanilla JavaScript, use dynamic import():
// router.js
const routes = {
'/dashboard': () => import('./routes/dashboard.js'),
'/analytics': () => import('./routes/analytics.js'),
'/settings': () => import('./routes/settings.js')
};
async function navigateTo(path) {
const loadRoute = routes[path];
if (loadRoute) {
const module = await loadRoute();
module.render(document.getElementById('app'));
}
}
Next.js handles this automatically—every file in pages/ becomes a code-split route:
// pages/dashboard.js - automatically code split
export default function Dashboard() {
return <div>Dashboard content</div>;
}
Component-Level Lazy Loading Strategies
Beyond routes, lazy load heavy components that users might not interact with: modals, rich text editors, charting libraries, video players.
Here’s lazy loading for a chart library:
import React, { lazy, Suspense, useState } from 'react';
const ChartComponent = lazy(() => import('./Chart'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>
Show Analytics Chart
</button>
{showChart && (
<Suspense fallback={<div>Loading chart...</div>}>
<ChartComponent data={analyticsData} />
</Suspense>
)}
</div>
);
}
For viewport-based loading, use Intersection Observer:
function LazyComponent({ children }) {
const [isVisible, setIsVisible] = useState(false);
const ref = useRef();
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: '50px' } // Load 50px before entering viewport
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, []);
return (
<div ref={ref}>
{isVisible ? children : <div style={{ height: '400px' }} />}
</div>
);
}
Preload anticipated user actions with link prefetching:
function Navigation() {
const prefetchSettings = () => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = '/static/js/settings.chunk.js';
document.head.appendChild(link);
};
return (
<nav>
<a href="/settings" onMouseEnter={prefetchSettings}>
Settings
</a>
</nav>
);
}
Optimizing Third-Party Dependencies
Third-party libraries often dominate bundle size. Moment.js alone adds 67KB minified. Lodash adds 71KB if you import the entire library.
Replace heavy libraries with lighter alternatives:
// Before: moment.js (67KB)
import moment from 'moment';
const formatted = moment().format('YYYY-MM-DD');
// After: date-fns (2-3KB per function)
import { format } from 'date-fns';
const formatted = format(new Date(), 'yyyy-MM-dd');
Load analytics and third-party scripts conditionally:
// Only load analytics after user consent
function loadAnalytics() {
if (userConsent) {
import('./analytics').then(({ initAnalytics }) => {
initAnalytics();
});
}
}
Use Webpack externals to load large libraries from CDN:
// webpack.config.js
module.exports = {
externals: {
react: 'React',
'react-dom': 'ReactDOM'
}
};
<!-- index.html -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
Image and Media Lazy Loading
Images often constitute 50%+ of page weight. Native lazy loading is now widely supported:
<img src="hero.jpg" alt="Hero image" loading="eager">
<img src="below-fold.jpg" alt="Below fold" loading="lazy">
Combine with responsive images:
<img
srcset="
small.jpg 400w,
medium.jpg 800w,
large.jpg 1200w
"
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
src="medium.jpg"
alt="Responsive image"
loading="lazy"
>
For background images, use Intersection Observer:
const lazyBackgrounds = document.querySelectorAll('.lazy-bg');
const bgObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const element = entry.target;
element.style.backgroundImage = `url(${element.dataset.bg})`;
element.classList.remove('lazy-bg');
bgObserver.unobserve(element);
}
});
});
lazyBackgrounds.forEach(bg => bgObserver.observe(bg));
Measuring and Monitoring Performance
Set performance budgets in Webpack:
// webpack.config.js
module.exports = {
performance: {
maxEntrypointSize: 250000, // 250KB
maxAssetSize: 250000,
hints: 'error' // Fail build if exceeded
}
};
Integrate Lighthouse CI into your pipeline:
// lighthouserc.json
{
"ci": {
"collect": {
"numberOfRuns": 3
},
"assert": {
"assertions": {
"first-contentful-paint": ["error", {"maxNumericValue": 2000}],
"interactive": ["error", {"maxNumericValue": 3500}],
"total-byte-weight": ["error", {"maxNumericValue": 500000}]
}
}
}
}
Use the Performance API for custom metrics:
// Mark when component renders
performance.mark('dashboard-start');
// ... component renders ...
performance.mark('dashboard-end');
performance.measure('dashboard-render', 'dashboard-start', 'dashboard-end');
// Get measurement
const measure = performance.getEntriesByName('dashboard-render')[0];
console.log(`Dashboard rendered in ${measure.duration}ms`);
The key is continuous monitoring. Performance degrades gradually through incremental changes. Automated checks in CI/CD catch regressions before they reach production. Set budgets, measure religiously, and treat performance as a feature, not an afterthought.