Lazy Loading & Code Splitting
11 min read•Frontend Patterns & Concepts
Lazy Loading & Code Splitting
Modern web applications can grow to megabytes of JavaScript. Users shouldn't wait for code they don't need yet. Lazy loading and code splitting are essential techniques for delivering fast, responsive experiences.
Why Lazy Load?
The Cost of Large Bundles
PLAINTEXT
Bundle Size Impact:
┌─────────────────────────────────────────────────┐
│ Parse Time (3G mobile) │
│ 1MB JS ≈ 4-5 seconds just to parse │
│ Plus: Download time + Compile time + Execute │
├─────────────────────────────────────────────────┤
│ User Impact │
│ 1s delay = 7% conversion drop │
│ 3s load time = 53% bounce rate │
└─────────────────────────────────────────────────┘What to Lazy Load
JAVASCRIPT
// Prime candidates for lazy loading:
// 1. Routes not immediately visible
const AdminPanel = lazy(() => import("./AdminPanel"));
// 2. Below-the-fold content
const Comments = lazy(() => import("./Comments"));
// 3. Conditionally rendered components
const Modal = lazy(() => import("./Modal"));
// 4. Heavy libraries
const Chart = lazy(() => import("./Chart")); // Includes chart.js
// 5. Features behind feature flags
const BetaFeature = lazy(() =>
featureFlags.beta ? import("./BetaFeature") : import("./Placeholder"),
);Native Lazy Loading
Images and Iframes
HTML
<!-- Native browser lazy loading -->
<img src="hero.jpg" loading="eager" />
<!-- Default: load immediately -->
<img src="footer.jpg" loading="lazy" />
<!-- Defer until near viewport -->
<iframe src="video.html" loading="lazy"></iframe>
<!-- With fallback dimensions to prevent layout shift -->
<img
src="photo.jpg"
loading="lazy"
width="800"
height="600"
alt="Description"
/>Intersection Observer Fallback
JAVASCRIPT
class LazyLoader {
constructor() {
// Check for native support
this.supportsNative = "loading" in HTMLImageElement.prototype;
if (!this.supportsNative) {
this.setupObserver();
}
}
setupObserver() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
this.observer.unobserve(entry.target);
}
});
},
{ rootMargin: "100px" },
);
// Observe images without native support
document.querySelectorAll("img[data-src]").forEach((img) => {
this.observer.observe(img);
});
}
loadImage(img) {
img.src = img.dataset.src;
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
img.removeAttribute("data-src");
}
}JavaScript Code Splitting
Dynamic Imports
JAVASCRIPT
// Static import (bundled together)
import { heavyFunction } from "./heavyModule";
// Dynamic import (separate chunk)
const loadHeavyModule = async () => {
const { heavyFunction } = await import("./heavyModule");
return heavyFunction();
};
// Conditional loading
button.addEventListener("click", async () => {
// Only loads when user clicks
const { processData } = await import("./dataProcessor");
processData(data);
});Webpack Magic Comments
JAVASCRIPT
// Named chunks for debugging
const Editor = () =>
import(
/* webpackChunkName: "editor" */
"./Editor"
);
// Prefetch (low priority, load during idle)
const Settings = () =>
import(
/* webpackChunkName: "settings" */
/* webpackPrefetch: true */
"./Settings"
);
// Preload (high priority, load immediately)
const Dashboard = () =>
import(
/* webpackChunkName: "dashboard" */
/* webpackPreload: true */
"./Dashboard"
);
// Disable prefetch/preload
const AdminOnly = () =>
import(
/* webpackPrefetch: false */
"./AdminPanel"
);Route-Based Splitting
JAVASCRIPT
// React Router example
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
// Each route is a separate chunk
const Home = lazy(() => import("./pages/Home"));
const Products = lazy(() => import("./pages/Products"));
const Product = lazy(() => import("./pages/Product"));
const Cart = lazy(() => import("./pages/Cart"));
const Checkout = lazy(() => import("./pages/Checkout"));
const Account = lazy(() => import("./pages/Account"));
function App() {
return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/products/:id" element={<Product />} />
<Route path="/cart" element={<Cart />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/account/*" element={<Account />} />
</Routes>
</Suspense>
);
}React.lazy and Suspense
Basic Usage
JAVASCRIPT
import { lazy, Suspense } from "react";
// Must use default export
const HeavyComponent = lazy(() => import("./HeavyComponent"));
function App() {
return (
<div>
<Suspense fallback={<Loading />}>
<HeavyComponent />
</Suspense>
</div>
);
}Named Export Workaround
JAVASCRIPT
// If module uses named exports
// heavyModule.js
export const HeavyComponent = () => {
/* ... */
};
// Lazy load with re-export
const HeavyComponent = lazy(() =>
import("./heavyModule").then((module) => ({
default: module.HeavyComponent,
})),
);Error Handling
JAVASCRIPT
import { lazy, Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
const DynamicComponent = lazy(() => import("./DynamicComponent"));
function App() {
return (
<ErrorBoundary
fallback={<Error message="Failed to load component" />}
onError={(error) => {
console.error("Chunk load failed:", error);
// Report to error tracking
}}
>
<Suspense fallback={<Loading />}>
<DynamicComponent />
</Suspense>
</ErrorBoundary>
);
}
// Custom error boundary for retry
class RetryErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
retry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<div>
<p>Failed to load. Please try again.</p>
<button onClick={this.retry}>Retry</button>
</div>
);
}
return this.props.children;
}
}Nested Suspense Boundaries
JAVASCRIPT
function Dashboard() {
return (
<div className="dashboard">
<Header /> {/* Not lazy - critical */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* Lazy loaded */}
</Suspense>
<main>
<Suspense fallback={<ChartSkeleton />}>
<Charts /> {/* Heavy charts library */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable /> {/* Lazy with own loading */}
</Suspense>
</main>
</div>
);
}Loading Strategies
Preloading Critical Chunks
JAVASCRIPT
// Preload on hover (likely to click)
function NavigationLink({ to, children }) {
const preload = () => {
const component = routeComponents[to];
if (component?.preload) {
component.preload();
}
};
return (
<Link to={to} onMouseEnter={preload} onFocus={preload}>
{children}
</Link>
);
}
// Create preloadable lazy components
function lazyWithPreload(factory) {
const Component = lazy(factory);
Component.preload = factory;
return Component;
}
const Settings = lazyWithPreload(() => import("./Settings"));Progressive Loading
JAVASCRIPT
// Load critical content first, enhance progressively
function Article({ id }) {
const [article, setArticle] = useState(null);
useEffect(() => {
// 1. Load article content immediately
fetchArticle(id).then(setArticle);
// 2. Preload comments (likely to scroll down)
const commentsPromise = import("./Comments");
// 3. Preload share tools (might use)
const timer = setTimeout(() => {
import("./ShareTools");
}, 2000);
return () => clearTimeout(timer);
}, [id]);
if (!article) return <ArticleSkeleton />;
return (
<article>
<h1>{article.title}</h1>
<div>{article.content}</div>
<Suspense fallback={<CommentsSkeleton />}>
<Comments articleId={id} />
</Suspense>
</article>
);
}Component-Level Code Splitting
JAVASCRIPT
// Split by component complexity
const SimpleModal = ({ children, ...props }) => (
<div className="modal">{children}</div>
);
const RichModal = lazy(() => import("./RichModal"));
function Modal({ variant = "simple", ...props }) {
if (variant === "simple") {
return <SimpleModal {...props} />;
}
return (
<Suspense fallback={<SimpleModal {...props} />}>
<RichModal {...props} />
</Suspense>
);
}Library Splitting
Heavy Dependencies
JAVASCRIPT
// Chart libraries are huge - load on demand
const ChartComponent = lazy(async () => {
// Load chart library first
await import("chart.js/auto");
// Then the component that uses it
return import("./Chart");
});
// Date library splitting
async function formatDate(date, format) {
// Only load date-fns when needed
const { format: formatFn } = await import("date-fns");
return formatFn(date, format);
}
// Markdown with syntax highlighting
const MarkdownRenderer = lazy(async () => {
// Parallel load
const [{ marked }, { highlight }] = await Promise.all([
import("marked"),
import("highlight.js"),
]);
// Configure
marked.setOptions({
highlight: (code, lang) =>
highlight.highlight(code, { language: lang }).value,
});
return import("./MarkdownRenderer");
});Tree Shaking with Dynamic Imports
JAVASCRIPT
// ❌ Imports entire lodash
import _ from "lodash";
_.debounce(fn, 100);
// ✅ Tree-shakeable
import debounce from "lodash/debounce";
debounce(fn, 100);
// ✅ Dynamic import of specific function
async function handleResize() {
const { debounce } = await import("lodash-es/debounce");
return debounce(updateLayout, 100);
}Image Optimization
Responsive Images
HTML
<!-- Multiple sizes for different viewports -->
<img
srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w"
sizes="
(max-width: 400px) 400px,
(max-width: 800px) 800px,
1200px
"
src="hero-800.jpg"
alt="Hero image"
loading="lazy"
/>
<!-- Art direction with picture -->
<picture>
<source
media="(max-width: 600px)"
srcset="hero-mobile.webp"
type="image/webp"
/>
<source media="(max-width: 600px)" srcset="hero-mobile.jpg" />
<source srcset="hero-desktop.webp" type="image/webp" />
<img src="hero-desktop.jpg" alt="Hero" loading="lazy" />
</picture>Blur-Up Technique
JAVASCRIPT
function ProgressiveImage({ src, placeholder, alt }) {
const [isLoaded, setIsLoaded] = useState(false);
const [currentSrc, setCurrentSrc] = useState(placeholder);
useEffect(() => {
const img = new Image();
img.src = src;
img.onload = () => {
setCurrentSrc(src);
setIsLoaded(true);
};
}, [src]);
return (
<img
src={currentSrc}
alt={alt}
className={`progressive-image ${isLoaded ? "loaded" : "loading"}`}
style={{
filter: isLoaded ? "none" : "blur(20px)",
transition: "filter 0.3s ease-out",
}}
/>
);
}
// Usage with tiny placeholder
<ProgressiveImage
placeholder="data:image/jpeg;base64,/9j/4AAQSkZJ..." // 20px blurred
src="full-image.jpg"
alt="Photo"
/>;Script Loading Strategies
Script Attributes
HTML
<!-- Normal: blocks parsing -->
<script src="app.js"></script>
<!-- Async: downloads parallel, executes immediately when ready -->
<!-- Order not guaranteed -->
<script async src="analytics.js"></script>
<script async src="ads.js"></script>
<!-- Defer: downloads parallel, executes after parsing -->
<!-- Order preserved -->
<script defer src="app.js"></script>
<script defer src="secondary.js"></script>
<!-- Module: deferred by default -->
<script type="module" src="app.mjs"></script>Dynamic Script Loading
JAVASCRIPT
function loadScript(src, options = {}) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.async = options.async ?? true;
if (options.module) {
script.type = "module";
}
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// Load third-party on interaction
async function initChat() {
await loadScript("https://chat-widget.com/widget.js");
ChatWidget.init({ apiKey: "xxx" });
}
chatButton.addEventListener("click", initChat, { once: true });Resource Hints
HTML
<head>
<!-- DNS prefetch for external domains -->
<link rel="dns-prefetch" href="https://api.example.com" />
<!-- Preconnect: DNS + TCP + TLS -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<!-- Prefetch: low-priority future resource -->
<link rel="prefetch" href="/next-page.js" />
<!-- Preload: high-priority current page resource -->
<link rel="preload" href="/critical.css" as="style" />
<link rel="preload" href="/hero.webp" as="image" />
<link rel="preload" href="/fonts/custom.woff2" as="font" crossorigin />
<!-- Modulepreload for ES modules -->
<link rel="modulepreload" href="/app.mjs" />
</head>Performance Metrics
Measuring Load Performance
JAVASCRIPT
// Measure chunk load time
function lazyWithMetrics(factory, chunkName) {
return lazy(async () => {
const start = performance.now();
try {
const module = await factory();
const duration = performance.now() - start;
console.log(`Chunk "${chunkName}" loaded in ${duration}ms`);
// Report to analytics
performance.measure(`chunk-${chunkName}`, {
start,
duration,
});
return module;
} catch (error) {
console.error(`Failed to load chunk "${chunkName}"`, error);
throw error;
}
});
}
const Dashboard = lazyWithMetrics(() => import("./Dashboard"), "dashboard");Bundle Analysis
JAVASCRIPT
// package.json
{
"scripts": {
"analyze": "webpack-bundle-analyzer stats.json",
"build:stats": "webpack --json > stats.json"
}
}
// Next.js
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// config
});
// Run: ANALYZE=true npm run buildCommon Patterns
Conditional Feature Loading
JAVASCRIPT
// Load features based on user/environment
async function initApp() {
const features = await fetch("/api/features").then((r) => r.json());
if (features.analytics) {
const { initAnalytics } = await import("./analytics");
initAnalytics();
}
if (features.chat) {
const { initChat } = await import("./chat");
initChat();
}
if (features.aiAssistant) {
const { AIAssistant } = await import("./aiAssistant");
new AIAssistant();
}
}
// Load admin tools only for admins
function AdminTools() {
const { user } = useAuth();
if (!user?.isAdmin) return null;
return (
<Suspense fallback={null}>
<LazyAdminTools />
</Suspense>
);
}Parallel Loading
JAVASCRIPT
// Load multiple chunks in parallel
async function loadDashboard() {
const [{ Charts }, { DataGrid }, { Widgets }] = await Promise.all([
import("./Charts"),
import("./DataGrid"),
import("./Widgets"),
]);
return { Charts, DataGrid, Widgets };
}
// React component with parallel lazy
function Dashboard() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<ParallelLoader
components={{
charts: () => import("./Charts"),
grid: () => import("./DataGrid"),
widgets: () => import("./Widgets"),
}}
render={({ Charts, DataGrid, Widgets }) => (
<div className="dashboard">
<Charts />
<DataGrid />
<Widgets />
</div>
)}
/>
</Suspense>
);
}Summary
| Technique | When to Use | Impact |
|---|---|---|
| Route splitting | Multi-page apps | Major - reduces initial bundle |
| Component splitting | Heavy/conditional components | Medium - defers non-critical |
| Library splitting | Large dependencies | Major - reduces bundle size |
| Native lazy loading | Images, iframes | Easy win - browser handles it |
| Prefetching | Predictable navigation | Improves perceived performance |
| Preloading | Critical resources | Faster initial render |
Key Principles:
- Split at natural boundaries (routes, features)
- Measure before and after - don't guess
- Provide good loading states (skeletons, not spinners)
- Handle errors gracefully with retry options
- Prefetch what users are likely to need next
- Keep critical path lean