Difficulty: Advanced
Lazy loading is a performance optimization technique that defers the loading of non-critical resources until they are actually needed. Instead of loading every image, iframe, and media element when the page first loads, lazy loading waits until these elements are about to enter the viewport before fetching them. This dramatically reduces initial page load time, saves bandwidth for users who never scroll to certain content, and decreases server load by spreading resource requests over time.
Native lazy loading in HTML is achieved through the loading attribute, which accepts three values: 'lazy', 'eager', and 'auto'. Setting loading="lazy" on an img or iframe element tells the browser to defer loading until the element is near the viewport. The browser handles all the complexity internally, including calculating distance thresholds based on network conditions and device capabilities. This is the simplest approach and requires zero JavaScript, but it is only supported for img and iframe elements and offers limited control over the loading threshold.
The Intersection Observer API provides a JavaScript-based approach to lazy loading with far more control. You create an IntersectionObserver instance with a callback and options (root, rootMargin, threshold), then observe target elements. When an observed element intersects with the root (viewport by default), the callback fires and you can swap a placeholder src with the real image URL stored in a data attribute. This approach works for any element type, supports custom thresholds and root margins for pre-loading content before it enters the viewport, and can trigger animations or other effects based on visibility.
Before Intersection Observer, developers relied on scroll event listeners with getBoundingClientRect() to detect element positions. This older approach is significantly less performant because scroll events fire at a very high rate and getBoundingClientRect() forces layout recalculation (reflow). The Intersection Observer API was specifically designed to replace this pattern, running asynchronously off the main thread and batching intersection calculations efficiently. Always prefer Intersection Observer over scroll-based detection in modern code.
A comprehensive lazy loading strategy considers several factors: above-the-fold images should use loading="eager" or have no loading attribute to ensure they load immediately. Below-the-fold images use loading="lazy". Critical resources like hero images, logos, and LCP (Largest Contentful Paint) elements should never be lazy loaded. For background images set via CSS, Intersection Observer is the only option since the loading attribute does not apply to CSS properties. Placeholder strategies include low-quality image placeholders (LQIP), solid color blocks, skeleton screens, or blur-up effects that provide visual feedback while the full image loads.
<!-- Lazy load images below the fold -->
<img
src="hero.jpg"
alt="Hero banner"
width="1200"
height="600"
loading="eager"
/>
<img
src="product-1.jpg"
alt="Product photo"
width="400"
height="300"
loading="lazy"
/>
<!-- Lazy load iframes -->
<iframe
src="https://www.youtube.com/embed/abc123"
width="560"
height="315"
loading="lazy"
title="Tutorial video"
allowfullscreen
></iframe>
<!-- Always set width and height to prevent layout shift -->
<img
src="gallery-photo.jpg"
alt="Gallery image"
width="800"
height="600"
loading="lazy"
decoding="async"
/>
The loading attribute is the simplest way to implement lazy loading. Set loading='eager' for above-the-fold critical images and loading='lazy' for everything else. Always include width and height attributes to reserve space and prevent Cumulative Layout Shift (CLS). The decoding='async' attribute allows the browser to decode the image off the main thread.
<!-- HTML: use data-src instead of src -->
<img
class="lazy"
data-src="large-photo.jpg"
src="placeholder.svg"
alt="Lazy loaded photo"
width="800"
height="600"
/>
<img
class="lazy"
data-src="another-photo.jpg"
src="placeholder.svg"
alt="Another photo"
width="800"
height="600"
/>
<script>
const lazyImages = document.querySelectorAll('img.lazy');
const imageObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
// Swap placeholder with real source
img.src = img.dataset.src;
img.classList.remove('lazy');
img.classList.add('loaded');
// Stop observing this image
observer.unobserve(img);
}
});
},
{
// Start loading 200px before entering viewport
rootMargin: '200px 0px',
threshold: 0.01,
}
);
lazyImages.forEach((img) => imageObserver.observe(img));
</script>
This approach stores the real image URL in data-src and uses a lightweight placeholder in src. The IntersectionObserver watches each image and swaps the source when the image is 200px from entering the viewport. After loading, the observer stops watching that image to free resources.
<style>
.blur-container {
position: relative;
overflow: hidden;
}
.blur-container img.lazy {
filter: blur(20px);
transition: filter 0.5s ease;
}
.blur-container img.loaded {
filter: blur(0);
}
</style>
<div class="blur-container">
<!-- Tiny 20px wide base64 placeholder -->
<img
class="lazy"
src="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
data-src="full-resolution.jpg"
alt="Photo with blur-up effect"
width="800"
height="600"
/>
</div>
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
const fullSrc = img.dataset.src;
// Preload the full image
const preloader = new Image();
preloader.onload = () => {
img.src = fullSrc;
img.classList.remove('lazy');
img.classList.add('loaded');
};
preloader.src = fullSrc;
observer.unobserve(img);
}
});
});
document.querySelectorAll('img.lazy').forEach((img) => {
observer.observe(img);
});
</script>
The blur-up technique starts with a tiny base64-encoded placeholder that is displayed blurred. When the full image loads, the blur filter is removed with a CSS transition, creating a smooth reveal effect. The Image() constructor preloads the full image in the background so the transition only starts when the image is fully downloaded.
loading attribute, Intersection Observer, Native Lazy Loading, JavaScript Lazy Loading, Image Optimization