Lazy Loading & Code Splitting
Deferring asset and code loading until actually needed by the user.
Overview
Lazy loading defers loading a resource (image, module, data) until it is actually needed, rather than loading everything upfront. It reduces initial load time, memory consumption, and unnecessary data transfer. In frontend development, it applies to images below the fold, route-split JavaScript bundles, and deferred component rendering. In databases and ORMs, it loads related associations on first access rather than at query time.
Origin
Lazy initialisation patterns appear in programming literature from the 1980s. ORM lazy loading was popularised by Hibernate (Gavin King, 2001) and became controversial when it produced N+1 query problems. Browser native lazy loading for images (loading="lazy" attribute) was standardised by W3C and shipped in Chrome 76 (2019). React.lazy (2018) and dynamic import() (ES2020) enabled code splitting at the module level.
Examples
Route-level code splitting with React.lazy and Suspense
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import LoadingSpinner from './components/LoadingSpinner';
// Each route is a separate JS chunk loaded only when the route is visited
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Orders = lazy(() => import('./pages/Orders'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
export function AppRoutes() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/orders" element={<Orders />} />
{/* Analytics module includes heavy charting library: Chart.js (180KB gzipped)
Only loaded when user navigates to /analytics */}
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}Each lazy() call creates a separate webpack/Rollup chunk. Analytics.tsx which imports Chart.js (180KB gzipped) only loads when the user navigates to /analytics, not on the initial page load. Suspense provides a fallback while the chunk is fetching.
Intersection Observer for image lazy loading in TypeScript
function lazyLoadImages(): void {
const images = document.querySelectorAll<HTMLImageElement>('img[data-src]');
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
img.src = img.dataset.src!;
img.removeAttribute('data-src');
observer.unobserve(img);
}
});
},
{
rootMargin: '200px 0px', // Start loading 200px before entering viewport
threshold: 0.01,
}
);
images.forEach(img => observer.observe(img));
} else {
// Fallback: load all images for browsers without IntersectionObserver
images.forEach(img => {
img.src = img.dataset.src!;
img.removeAttribute('data-src');
});
}
}rootMargin: "200px 0px" pre-loads images 200px before they enter the viewport, preventing visible loading jank. The native loading="lazy" HTML attribute (Chrome 76+, Firefox 75+) handles the common case without JavaScript; custom IO is needed for finer control or browser compatibility.
Use Cases
- 01Image galleries and product listing pages where images below the fold should not block initial render
- 02Single-page applications where different routes import heavy libraries (chart.js, pdf.js, video players) that should not load on every page visit
- 03ORM associations in list views where related data (order.customer.address) is fetched only for the records that need it, using eager loading patterns
- 04React components (modal content, tab panels, sidebar widgets) that are conditionally rendered and should not parse/execute until shown
When Not to Use
- //Do not lazy load above-the-fold content (hero images, critical CSS, above-fold product cards); it delays the Largest Contentful Paint (LCP) metric which is a Core Web Vital
- //Do not apply lazy loading to small JavaScript modules; the network round-trip for a 5KB module costs more than just bundling it into the main chunk
- //Do not use ORM lazy loading in list endpoints; it is the primary cause of N+1 queries. Use eager loading (includes in ActiveRecord, include in Prisma) for associations rendered in lists
Technical Notes
- Webpack's magic comment /* webpackChunkName: "analytics" */ names the generated chunk file; without it chunks get numeric names (3.js) making debugging and cache invalidation difficult
- Prefetching (link rel="prefetch") loads a chunk during idle time after the current page is interactive; preloading (link rel="preload") loads it with high priority during the current navigation. Next.js uses prefetch on Link hover by default
- Core Web Vitals: LCP (Largest Contentful Paint) must be under 2.5s; lazy loading the hero image without preload causes it to load late and fail the LCP budget. Use fetchpriority="high" on above-fold images in modern browsers
- React's Suspense boundary must wrap the lazy component; if a suspense boundary is too high in the tree, an error in one component shows the fallback for an entire section of the app. Use multiple boundaries with appropriate granularity
More in Performance