Introduction
Lazy loading defers the loading of non-critical resources until they're actually needed. Instead of downloading everything upfront, you load content as users scroll or interact. This dramatically reduces initial page weight.
Modern browsers support native lazy loading for images and iframes, but the pattern extends to components, routes, and entire features.
This guide covers patterns from simple image deferral to advanced code-splitting strategies.
Key Concepts
Native Lazy Loading
<img src="photo.webp" loading="lazy" alt="Photo" width="800" height="600">
<iframe src="https://youtube.com/embed/..." loading="lazy"></iframe>
Intersection Observer API
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.src = entry.target.dataset.src;
observer.unobserve(entry.target);
}
});
},
{ rootMargin: '200px' }
);
document.querySelectorAll('[data-src]').forEach(el => observer.observe(el));
Dynamic Imports
import { lazy, Suspense } from 'react';
const HeavyChart = lazy(() => import('./HeavyChart'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
{showChart && <HeavyChart data={data} />}
</Suspense>
);
}
Practical Examples
1. Route-Based Code Splitting
import dynamic from 'next/dynamic';
const AdminDashboard = dynamic(() => import('@/components/AdminDashboard'), {
loading: () => <DashboardSkeleton />,
ssr: false,
});
2. Below-Fold Component Loading
function LazySection({ children }) {
const [visible, setVisible] = useState(false);
const ref = useRef(null);
useEffect(() => {
const obs = new IntersectionObserver(
([e]) => { if (e.isIntersecting) { setVisible(true); obs.disconnect(); } },
{ rootMargin: '100px' }
);
if (ref.current) obs.observe(ref.current);
return () => obs.disconnect();
}, []);
return <div ref={ref}>{visible ? children : <Skeleton />}</div>;
}
3. Lazy Loading Third-Party Scripts
let loaded = false;
function loadAnalytics() {
if (loaded) return;
loaded = true;
const s = document.createElement('script');
s.src = 'https://analytics.example.com/script.js';
document.head.appendChild(s);
}
['click','scroll','keydown'].forEach(e =>
document.addEventListener(e, loadAnalytics, { once: true })
);
Best Practices
- ✅ Use native loading='lazy' for images and iframes below the fold
- ✅ Never lazy-load the LCP element
- ✅ Provide meaningful loading states (skeletons, not spinners)
- ✅ Set rootMargin on IntersectionObserver to preload before visible
- ✅ Code-split routes so users only download code for the current page
- ❌ Don't lazy-load everything — above-fold content should load immediately
Common Pitfalls
- 🚫 Lazy loading the hero image — directly hurts LCP
- 🚫 No loading state — users see blank space
- 🚫 Too many dynamic imports — each creates a network request
- 🚫 Not testing on slow connections