Loading all images, videos, and components upfront kills
page load performance. Users wait for megabytes of content they might never see. Traditional scroll event listeners
are janky and inefficient. The Intersection Observer API solves this elegantly, letting you lazy-load content
precisely when it enters the viewport—smoothly, efficiently, and with minimal code.
This guide covers production-ready lazy loading patterns
that transform slow, heavy pages into fast, responsive experiences. We’ll implement image lazy loading, infinite
scroll, animation triggers, and more.
Why Intersection Observer Transforms Performance
The Traditional Approach Problems
Old scroll-based lazy loading suffers from:
- Performance issues: Scroll events fire constantly, killing performance
- Complex calculations: Manual viewport detection is error-prone
- Battery drain: Continuous scroll monitoring wastes resources
- Jank: Calculations on main thread cause stuttering
- Edge cases: Handling all scenarios is complex
Intersection Observer Benefits
- Async: Runs off main thread, no jank
- Efficient: Browser-optimized, no manual calculations
- Simple API: Clean, declarative code
- Battery-friendly: Only triggers when needed
- Flexible: Configurable thresholds and root margins
Pattern 1: Basic Image Lazy Loading
Load Images On Demand
<!-- HTML markup -->
<img
data-src="large-image.jpg"
alt="Description"
class="lazy-image"
src="placeholder.jpg"
>
// Basic lazy loading
class LazyImageLoader {
constructor() {
this.images = document.querySelectorAll('img[data-src]');
this.observer = this.createObserver();
this.observe();
}
createObserver() {
const options = {
root: null, // viewport
rootMargin: '50px', // start loading 50px before entering viewport
threshold: 0.01 // trigger when 1% visible
};
return new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
observer.unobserve(entry.target); // Stop observing once loaded
}
});
}, options);
}
loadImage(img) {
const src = img.getAttribute('data-src');
if (!src) return;
// Create new image to preload
const newImage = new Image();
newImage.onload = () => {
img.src = src;
img.classList.add('loaded');
img.removeAttribute('data-src');
};
newImage.onerror = () => {
console.error(`Failed to load image: ${src}`);
img.classList.add('error');
};
newImage.src = src;
}
observe() {
this.images.forEach(img => this.observer.observe(img));
}
}
// Initialize
const lazyLoader = new LazyImageLoader();
/* CSS for smooth transitions */
.lazy-image {
opacity: 0;
transition: opacity 0.3s ease-in;
}
.lazy-image.loaded {
opacity: 1;
}
.lazy-image.error {
opacity: 0.3;
border: 2px solid red;
}
Pattern 2: Progressive Image Loading
Blur-Up Effect Like Medium
<div class="progressive-image">
<img
src="tiny-placeholder.jpg"
data-src="full-image.jpg"
alt="Description"
class="image-placeholder"
>
<img
data-src="full-image.jpg"
alt="Description"
class="image-full"
>
</div>
class ProgressiveImageLoader {
constructor() {
this.containers = document.querySelectorAll('.progressive-image');
this.observer = this.createObserver();
this.observe();
}
createObserver() {
return new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadProgressiveImage(entry.target);
this.observer.unobserve(entry.target);
}
});
}, {
rootMargin: '100px'
});
}
loadProgressiveImage(container) {
const placeholder = container.querySelector('.image-placeholder');
const fullImage = container.querySelector('.image-full');
const src = fullImage.getAttribute('data-src');
// Load full image
const img = new Image();
img.onload = () => {
fullImage.src = src;
fullImage.classList.add('loaded');
// Fade out placeholder after full image loads
setTimeout(() => {
placeholder.style.opacity = '0';
}, 100);
};
img.src = src;
}
observe() {
this.containers.forEach(container => {
this.observer.observe(container);
});
}
}
.progressive-image {
position: relative;
overflow: hidden;
}
.image-placeholder {
filter: blur(20px);
transform: scale(1.1);
transition: opacity 0.3s ease;
}
.image-full {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.5s ease;
}
.image-full.loaded {
opacity: 1;
}
Pattern 3: Infinite Scroll
Load More Content As User Scrolls
class InfiniteScroll {
constructor(options) {
this.container = options.container;
this.loadMore = options.loadMore;
this.sentinel = this.createSentinel();
this.page = 1;
this.loading = false;
this.hasMore = true;
this.observer = this.createObserver();
this.observer.observe(this.sentinel);
}
createSentinel() {
const sentinel = document.createElement('div');
sentinel.className = 'scroll-sentinel';
sentinel.style.height = '1px';
this.container.appendChild(sentinel);
return sentinel;
}
createObserver() {
return new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !this.loading && this.hasMore) {
this.loadMoreContent();
}
});
}, {
rootMargin: '200px' // Start loading before reaching bottom
});
}
async loadMoreContent() {
this.loading = true;
this.showLoader();
try {
const data = await this.loadMore(this.page);
if (data && data.length > 0) {
this.appendContent(data);
this.page++;
} else {
this.hasMore = false;
this.showEndMessage();
}
} catch (error) {
console.error('Failed to load more:', error);
this.showError();
} finally {
this.loading = false;
this.hideLoader();
}
}
appendContent(items) {
items.forEach(item => {
const element = this.createItemElement(item);
this.container.insertBefore(element, this.sentinel);
});
}
createItemElement(item) {
const div = document.createElement('div');
div.className = 'item';
div.innerHTML = `
${item.title}
${item.description}
`;
return div;
}
showLoader() {
this.sentinel.innerHTML = '<div class="loader">Loading...</div>';
}
hideLoader() {
this.sentinel.innerHTML = '';
}
showEndMessage() {
this.sentinel.innerHTML = '<div class="end-message">No more items</div>';
this.observer.unobserve(this.sentinel);
}
}
// Usage
const scroll = new InfiniteScroll({
container: document.querySelector('#content'),
loadMore: async (page) => {
const response = await fetch(`/api/items?page=${page}`);
return response.json();
}
});
Pattern 4: Trigger Animations On Scroll
Animate Elements As They Enter Viewport
class ScrollAnimator {
constructor() {
this.elements = document.querySelectorAll('[data-animate]');
this.observer = this.createObserver();
this.observe();
}
createObserver() {
return new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.animateElement(entry.target);
// Option: Only animate once
if (!entry.target.dataset.animateRepeat) {
this.observer.unobserve(entry.target);
}
} else if (entry.target.dataset.animateRepeat) {
// Reset animation if repeat is enabled
entry.target.classList.remove('animated');
}
});
}, {
threshold: 0.2, // 20% visible
rootMargin: '-50px'
});
}
animateElement(element) {
const animationType = element.dataset.animate;
const delay = element.dataset.animateDelay || 0;
setTimeout(() => {
element.classList.add('animated', `animate-${animationType}`);
}, delay);
}
observe() {
this.elements.forEach(el => this.observer.observe(el));
}
}
// Initialize
const animator = new ScrollAnimator();
<!-- HTML usage -->
<div data-animate="fade-in">Fades in</div>
<div data-animate="slide-up" data-animate-delay="200">Slides up with delay</div>
<div data-animate="scale" data-animate-repeat>Scales every time</div>
/* Animation CSS */
[data-animate] {
opacity: 0;
transition: all 0.6s ease;
}
.animate-fade-in.animated {
opacity: 1;
}
.animate-slide-up {
transform: translateY(50px);
}
.animate-slide-up.animated {
opacity: 1;
transform: translateY(0);
}
.animate-scale {
transform: scale(0.8);
}
.animate-scale.animated {
opacity: 1;
transform: scale(1);
}
Pattern 5: Video Lazy Loading
Load and Autoplay Videos When Visible
<video
class="lazy-video"
data-src="video.mp4"
poster="poster.jpg"
muted
loop
playsinline
></video>
class LazyVideoLoader {
constructor() {
this.videos = document.querySelectorAll('video[data-src]');
this.observer = this.createObserver();
this.observe();
}
createObserver() {
return new IntersectionObserver((entries) => {
entries.forEach(entry => {
const video = entry.target;
if (entry.isIntersecting) {
this.loadAndPlay(video);
} else {
// Pause when out of view to save resources
if (video.src) {
video.pause();
}
}
});
}, {
threshold: 0.5 // 50% visible
});
}
loadAndPlay(video) {
if (!video.src) {
const src = video.getAttribute('data-src');
video.src = src;
video.load();
}
video.play().catch(e => {
console.error('Autoplay failed:', e);
});
}
observe() {
this.videos.forEach(video => this.observer.observe(video));
}
}
// Initialize
const videoLoader = new LazyVideoLoader();
Pattern 6: Component Lazy Loading
Load Heavy Components On Demand
class ComponentLazyLoader {
constructor() {
this.placeholders = document.querySelectorAll('[data-component]');
this.observer = this.createObserver();
this.observe();
}
createObserver() {
return new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadComponent(entry.target);
this.observer.unobserve(entry.target);
}
});
}, {
rootMargin: '100px'
});
}
async loadComponent(placeholder) {
const componentName = placeholder.dataset.component;
placeholder.innerHTML = '<div class="loading">Loading...</div>';
try {
// Dynamic import
const module = await import(`./components/${componentName}.js`);
const Component = module.default;
// Initialize component
const instance = new Component(placeholder);
await instance.render();
placeholder.classList.add('loaded');
} catch (error) {
console.error(`Failed to load component: ${componentName}`, error);
placeholder.innerHTML = '<div class="error">Failed to load</div>';
}
}
observe() {
this.placeholders.forEach(p => this.observer.observe(p));
}
}
// Usage
const componentLoader = new ComponentLazyLoader();
<!-- Lazy load heavy components -->
<div data-component="Chart"></div>
<div data-component="DataTable"></div>
<div data-component="VideoPlayer"></div>
Pattern 7: Multiple Thresholds
Track Visibility Percentage
class VisibilityTracker {
constructor(element, callbacks) {
this.element = element;
this.callbacks = callbacks;
this.observer = this.createObserver();
this.observer.observe(element);
}
createObserver() {
return new IntersectionObserver((entries) => {
entries.forEach(entry => {
const ratio = entry.intersectionRatio;
// Find appropriate callback based on threshold
if (ratio >= 0.9 && this.callbacks.fullyVisible) {
this.callbacks.fullyVisible(entry);
} else if (ratio >= 0.5 && this.callbacks.mostlyVisible) {
this.callbacks.mostlyVisible(entry);
} else if (ratio >= 0.1 && this.callbacks.partiallyVisible) {
this.callbacks.partiallyVisible(entry);
} else if (ratio === 0 && this.callbacks.notVisible) {
this.callbacks.notVisible(entry);
}
});
}, {
threshold: [0, 0.1, 0.5, 0.9, 1.0]
});
}
}
// Usage: Track ad viewability
const adElement = document.querySelector('.advertisement');
const tracker = new VisibilityTracker(adElement, {
fullyVisible: (entry) => {
console.log('Ad 100% visible');
trackEvent('ad_fully_visible');
},
mostlyVisible: (entry) => {
console.log('Ad 50%+ visible');
trackEvent('ad_mostly_visible');
},
notVisible: (entry) => {
console.log('Ad not visible');
}
});
Real-World Example: Complete Page Optimizer
class PageOptimizer {
constructor() {
this.initImageLazyLoading();
this.initVideoLazyLoading();
this.initInfiniteScroll();
this.initScrollAnimations();
this.initAnalytics();
}
initImageLazyLoading() {
const images = document.querySelectorAll('img[data-src]');
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.add('loaded');
imageObserver.unobserve(img);
}
});
}, { rootMargin: '50px' });
images.forEach(img => imageObserver.observe(img));
}
initVideoLazyLoading() {
const videos = document.querySelectorAll('video[data-src]');
const videoObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const video = entry.target;
if (entry.isIntersecting) {
if (!video.src) {
video.src = video.dataset.src;
video.load();
}
video.play();
} else {
video.pause();
}
});
}, { threshold: 0.5 });
videos.forEach(video => videoObserver.observe(video));
}
initInfiniteScroll() {
const sentinel = document.querySelector('.scroll-sentinel');
if (!sentinel) return;
let page = 1;
const scrollObserver = new IntersectionObserver(async (entries) => {
if (entries[0].isIntersecting) {
const items = await this.fetchMoreItems(page++);
this.appendItems(items);
}
}, { rootMargin: '200px' });
scrollObserver.observe(sentinel);
}
initScrollAnimations() {
const animatedElements = document.querySelectorAll('[data-animate]');
const animationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animated');
}
});
}, { threshold: 0.2 });
animatedElements.forEach(el => animationObserver.observe(el));
}
initAnalytics() {
const trackableElements = document.querySelectorAll('[data-track-view]');
const analyticsObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const eventName = entry.target.dataset.trackView;
this.trackView(eventName);
analyticsObserver.unobserve(entry.target);
}
});
}, { threshold: 0.5 });
trackableElements.forEach(el => analyticsObserver.observe(el));
}
async fetchMoreItems(page) {
const response = await fetch(`/api/items?page=${page}`);
return response.json();
}
appendItems(items) {
const container = document.querySelector('#content');
items.forEach(item => {
const element = document.createElement('div');
element.innerHTML = item.html;
container.appendChild(element);
});
}
trackView(eventName) {
// Send to analytics
console.log('Tracked view:', eventName);
}
}
// Initialize everything
const optimizer = new PageOptimizer();
Performance Impact
| Metric | Without Lazy Loading | With Lazy Loading | Improvement |
|---|---|---|---|
| Initial Load | 4.2s | 1.1s | 74% faster |
| Data Transfer | 8.5 MB | 2.1 MB | 75% less |
| Time to Interactive | 5.8s | 1.8s | 69% faster |
Best Practices
- Use rootMargin: Start loading before element enters viewport (50-200px)
- Provide placeholders: Show skeleton or blur-up for better UX
- Unobserve after loading: Free up resources
- Handle errors: Provide fallback for failed loads
- Test on slow networks: Ensure good UX on 3G/4G
- Use appropriate thresholds: 0.01 for images, 0.5 for videos
- Combine with native lazy loading: Use loading=”lazy” as fallback
Common Pitfalls
- No placeholders: Causes layout shift (CLS)
- Wrong threshold: Too high means late loading, too low means early
- Not unobserving: Memory leaks from observing loaded elements
- Forgetting fallbacks: Always check browser support
- Lazy loading above fold: Don’t lazy load critical content
- No loading states: User doesn’t know content is loading
Browser Support Fallback
// Feature detection and fallback
if ('IntersectionObserver' in window) {
// Use Intersection Observer
const lazyLoader = new LazyImageLoader();
} else {
// Fallback: Load all images immediately
document.querySelectorAll('img[data-src]').forEach(img => {
img.src = img.dataset.src;
});
}
Key Takeaways
- Intersection Observer is the modern way to detect element visibility
- Perfect for lazy loading images, videos, and components
- Dramatically improves initial page load performance (50-75% faster)
- Enables infinite scroll without janky scroll event listeners
- Great for scroll-triggered animations and analytics tracking
- Use rootMargin to start loading before elements enter viewport
- Always provide placeholders to prevent layout shift
- Widely supported (95%+ browsers), with simple fallbacks
Intersection Observer transforms how we build performant web pages. By loading only what users actually see, you
deliver fast initial experiences while preserving smooth scrolling and animations. It’s one of the
highest-impact performance optimizations available—simple to implement, massive results.
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.