Programmatic lazy loading solves a problem native loading="lazy" cannot: fine-grained control over when, at what threshold, and in what priority order media fetches begin. This page covers the full engineering path — from IntersectionObserver configuration through framework lifecycle integration, CLS prevention, and memory-safe teardown — within the broader Implementation Patterns for Viewport & Resize Tracking approach.
Concept Framing
Modern frontend performance budgets treat deferred media loading as a Core Web Vitals lever: deferring off-screen images directly improves Time to First Byte and Largest Contentful Paint (LCP), while poorly implemented lazy loading introduces Cumulative Layout Shift (CLS). Native loading="lazy" handles only the simplest scenario — a single image with no orchestration requirements. Production applications need:
- Prefetch distance tuned per connection speed and asset size
- Two-phase visibility triggers (preload at 200 px out, animate at 0 px)
- Coordinated teardown to prevent detached-DOM memory leaks
- Framework-compatible lifecycle hooks that survive hydration and route changes
IntersectionObserver provides a main-thread-friendly, frame-batched mechanism to cover all four. Its callbacks fire after layout and before paint, meaning attribute swaps inside them land in the same rendering cycle — eliminating the double-paint flicker of scroll-event approaches. For a deep dive into how the browser schedules these callbacks relative to rendering phases, see the IntersectionObserver API deep dive.
Spec / Signature Reference Table
The IntersectionObserver constructor and its options object control all timing and spatial behaviour of lazy loading triggers.
| Option / Property | Type | Lazy Loading Recommendation | Notes |
|---|---|---|---|
root |
Element | null |
null (viewport) |
Use a scroll-container element only for carousel or modal lazy loading |
rootMargin |
string |
"200px 0px" |
Extend root bounds to prefetch before visible; reduce on slow connections |
threshold |
number | number[] |
[0.01] for load; [0, 0.5] for two-phase |
Single value fires once; array fires at each crossing |
entry.isIntersecting |
boolean |
Gate attribute swap on true |
Also check entry.intersectionRatio > 0 for strict two-phase logic |
entry.boundingClientRect |
DOMRectReadOnly |
Validate dimensions before swap | Zero-area elements (hidden ancestors) report isIntersecting: false |
entry.rootBounds |
DOMRectReadOnly | null |
— | null when root is viewport and page is cross-origin framed |
entry.time |
DOMHighResTimeStamp |
Useful for analytics logging | Time since navigation start, not wall clock |
observer.observe(el) |
void |
Called once per placeholder | Calling twice on same element is a no-op |
observer.unobserve(el) |
void |
Required immediately after load swap | Removes target from internal C++ tracking queue |
observer.disconnect() |
void |
Required on component teardown | Flushes entire queue; subsequent observe() calls will throw after disconnect |
Step-by-Step Implementation
Step 1 — Create the shared observer
Instantiate once at the module or route level, not inside a per-element render function.
// lazy-observer.ts
interface LazyObserverOptions {
rootMargin?: string;
thresholds?: number[];
onLoad?: (el: HTMLImageElement | HTMLVideoElement) => void;
}
export function createLazyObserver(opts: LazyObserverOptions = {}): IntersectionObserver | null {
// SSR / non-browser guard
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
return null;
}
const {
rootMargin = '200px 0px',
thresholds = [0.01],
onLoad,
} = opts;
return new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const target = entry.target as HTMLImageElement | HTMLVideoElement;
swapDataAttributes(target);
onLoad?.(target);
// Immediately remove from tracking queue — critical for memory
observer.unobserve(target);
}
},
{ rootMargin, threshold: thresholds }
);
// Note: TypeScript requires the variable to be referenced inside the closure.
// In plain JS, replace 'observer' with the variable name used in the calling scope.
}
function swapDataAttributes(el: HTMLImageElement | HTMLVideoElement): void {
if (el.dataset.src) el.src = el.dataset.src;
if (el.dataset.srcset) (el as HTMLImageElement).srcset = el.dataset.srcset;
if (el.dataset.sizes) (el as HTMLImageElement).sizes = el.dataset.sizes;
el.removeAttribute('data-src');
el.removeAttribute('data-srcset');
el.removeAttribute('data-sizes');
el.classList.add('is-lazy-loaded');
}
Step 2 — Mark placeholder elements
Placeholders must carry explicit dimensions to prevent CLS. The browser reserves layout space before the fetch, so width and height are not optional.
<!-- Image with responsive srcset -->
<img
data-src="/images/hero-800.webp"
data-srcset="/images/hero-400.webp 400w, /images/hero-800.webp 800w"
data-sizes="(max-width: 600px) 400px, 800px"
width="800"
height="450"
alt="Dashboard screenshot showing real-time data"
class="lazy-placeholder"
loading="lazy"
/>
<!-- Video (poster + src deferred) -->
<video
data-src="/media/demo.mp4"
width="1280"
height="720"
muted
playsinline
class="lazy-placeholder"
></video>
Add this CSS alongside your existing stylesheet to prevent premature layout collapse:
.lazy-placeholder {
background-color: #f1f5f9; /* neutral placeholder fill */
/* aspect-ratio as progressive enhancement backup */
}
.is-lazy-loaded {
animation: lazy-fade 0.3s ease-in;
}
@keyframes lazy-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.is-lazy-loaded { animation: none; }
}
Step 3 — Register elements and handle the fallback
// main.ts
import { createLazyObserver } from './lazy-observer';
// Create once at the application entry point
const observer = createLazyObserver({ rootMargin: '200px 0px' });
function registerLazyElements(): void {
const elements = document.querySelectorAll<HTMLImageElement | HTMLVideoElement>(
'img[data-src], video[data-src]'
);
for (const el of elements) {
if (observer) {
observer.observe(el);
} else {
// Fallback: browser lacks IntersectionObserver — load immediately
swapDataAttributesFallback(el);
}
}
}
function swapDataAttributesFallback(el: HTMLImageElement | HTMLVideoElement): void {
if (el.dataset.src) el.src = el.dataset.src;
if (el.dataset.srcset) (el as HTMLImageElement).srcset = el.dataset.srcset ?? '';
}
// Run after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', registerLazyElements);
} else {
registerLazyElements();
}
// Teardown on SPA navigation
export function destroyLazyObserver(): void {
observer?.disconnect();
}
Step 4 — Wire teardown to the route or page lifecycle
For SPAs, call destroyLazyObserver() when the page component unmounts, then re-call registerLazyElements() after the new route's DOM is ready. Failure to disconnect leaves the browser tracking elements that may have been removed from the DOM.
Threshold / Configuration Variants
| Configuration | rootMargin |
threshold |
Best For |
|---|---|---|---|
| Aggressive prefetch | "400px 0px" |
[0.01] |
Video or large images on fast connections |
| Standard image | "200px 0px" |
[0.01] |
Most production image grids |
| Conservative / slow net | "50px 0px" |
[0.01] |
Mobile or data-saver contexts |
| Two-phase (fetch + animate) | "200px 0px" |
[0, 0.5] |
Fetch at 0 crossing; animate at 50% visible |
| Scroll carousel / inner scroll | "0px" |
[0.1] |
root set to the scroll container element |
| Above-fold override | — | — | Skip the observer; set src directly + fetchpriority="high" |
The IntersectionObserver threshold array is the primary dial for two-phase patterns. Passing [0, 0.5] means the callback fires when the element goes from not intersecting to intersecting (for the network fetch), and again when it reaches 50% visibility (for the CSS animation or analytics ping).
Edge Cases & Gotchas
Zero-area elements never fire. An element with display: none, visibility: hidden, or inside a hidden ancestor reports isIntersecting: false even when rootMargin would otherwise include it. Reveal elements before calling observe(), or handle the hidden-state case in your intersection callback.
Subpixel rounding at threshold 0. Browsers compute intersection ratios using floating-point arithmetic. A threshold of exactly 0 may fire when intersectionRatio is 0.000001 due to subpixel rounding — always gate on entry.isIntersecting rather than entry.intersectionRatio > 0 unless you explicitly need the ratio value.
iframe constraints. When the page is loaded inside a cross-origin iframe, entry.rootBounds returns null and rootMargin is ignored by the browser's security model. Test lazy loading in an iframe context explicitly if your page is embeddable. For details see the IntersectionObserver API deep dive.
Coalesced frames on fast scroll. At scroll speeds exceeding one viewport per frame, the browser may deliver multiple threshold crossings in a single callback batch. entries will contain multiple records for the same element. Always check entry.isIntersecting per entry — do not assume the last entry in the array reflects the current state.
rootMargin is applied to the root, not the element. A positive rootMargin expands the virtual root bounding box outward, so "200px 0px" means the observer fires when an element is within 200 px below the viewport bottom — exactly the prefetch behaviour you want. Negative values shrink the effective root, useful for requiring an element to be fully inside the viewport.
LCP conflict with hero images. Above-the-fold images that are your Largest Contentful Paint candidate must not be lazy loaded. The browser's speculative parser cannot discover data-src values; applying loading="lazy" or an observer to LCP images delays their fetch behind JavaScript parsing and causes measurable LCP regressions. Identify your LCP candidate at build time and render it with a live src and fetchpriority="high".
Framework Integration Patterns
React — custom hook
// useLazyMedia.ts
import { useEffect, useRef } from 'react';
interface UseLazyMediaOptions {
rootMargin?: string;
threshold?: number | number[];
}
export function useLazyMedia<T extends HTMLImageElement | HTMLVideoElement>(
opts: UseLazyMediaOptions = {}
): React.RefObject<T> {
const ref = useRef<T>(null);
const { rootMargin = '200px 0px', threshold = 0.01 } = opts;
useEffect(() => {
const el = ref.current;
if (!el || !('IntersectionObserver' in window)) {
// Fallback: load immediately
if (el?.dataset.src) el.src = el.dataset.src;
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
const target = entry.target as T;
if (target.dataset.src) target.src = target.dataset.src;
if (target.dataset.srcset) (target as HTMLImageElement).srcset = target.dataset.srcset;
target.removeAttribute('data-src');
target.removeAttribute('data-srcset');
target.classList.add('is-lazy-loaded');
observer.disconnect();
},
{ rootMargin, threshold }
);
observer.observe(el);
// Cleanup: fires on unmount and whenever opts change
return () => observer.disconnect();
}, [rootMargin, threshold]);
return ref;
}
// Usage:
// const imgRef = useLazyMedia<HTMLImageElement>({ rootMargin: '200px 0px' });
// <img ref={imgRef} data-src="/photo.webp" width={800} height={450} alt="..." />
Vue 3 — composable
// useLazyMedia.ts (Vue)
import { onMounted, onUnmounted, ref, type Ref } from 'vue';
export function useLazyMedia(rootMargin = '200px 0px'): Ref<HTMLImageElement | null> {
const elRef = ref<HTMLImageElement | null>(null);
let observer: IntersectionObserver | null = null;
onMounted(() => {
const el = elRef.value;
if (!el || !('IntersectionObserver' in window)) {
if (el?.dataset.src) el.src = el.dataset.src;
return;
}
observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
const target = entry.target as HTMLImageElement;
if (target.dataset.src) target.src = target.dataset.src;
if (target.dataset.srcset) target.srcset = target.dataset.srcset;
target.removeAttribute('data-src');
target.removeAttribute('data-srcset');
target.classList.add('is-lazy-loaded');
observer?.disconnect();
},
{ rootMargin, threshold: 0.01 }
);
observer.observe(el);
});
onUnmounted(() => {
observer?.disconnect();
observer = null;
});
return elRef;
}
// Usage in <script setup>:
// const imgRef = useLazyMedia('200px 0px');
// <img :ref="imgRef" data-src="/photo.webp" :width="800" :height="450" alt="..." />
Angular — directive
// lazy-media.directive.ts
import {
Directive, ElementRef, OnInit, OnDestroy, Input
} from '@angular/core';
@Directive({ selector: '[appLazyMedia]', standalone: true })
export class LazyMediaDirective implements OnInit, OnDestroy {
@Input() rootMargin = '200px 0px';
private observer: IntersectionObserver | null = null;
constructor(private host: ElementRef<HTMLImageElement | HTMLVideoElement>) {}
ngOnInit(): void {
const el = this.host.nativeElement;
if (!('IntersectionObserver' in window)) {
if (el.dataset['src']) el.src = el.dataset['src'];
return;
}
this.observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
const target = entry.target as HTMLImageElement;
if (target.dataset['src']) target.src = target.dataset['src'];
if (target.dataset['srcset']) target.srcset = target.dataset['srcset'];
target.removeAttribute('data-src');
target.removeAttribute('data-srcset');
target.classList.add('is-lazy-loaded');
this.observer?.disconnect();
},
{ rootMargin: this.rootMargin, threshold: 0.01 }
);
this.observer.observe(el);
}
ngOnDestroy(): void {
this.observer?.disconnect();
this.observer = null;
}
}
// Usage:
// <img appLazyMedia rootMargin="200px 0px" data-src="/photo.webp" width="800" height="450" alt="..." />
For lifecycle and memory management discipline across these patterns, the key rule is consistent: one observer instance per context, unobserve() per element on load, disconnect() per instance on teardown.
Debugging Checklist
-
Verify
rootMarginfires early enough. In DevTools Network panel, filter by Img/Media and set throttling to Slow 3G. Scroll toward a lazy image: the fetch should appear in the waterfall before the image enters the viewport. If it appears exactly at scroll-in, increaserootMargin. -
Check for detached DOM nodes. In Chrome DevTools Memory panel, take a heap snapshot after navigating away from a lazy-loaded page. Filter by "Detached". Persistent
HTMLImageElementnodes indicate a missingdisconnect()orunobserve()call. See Preventing Memory Leaks in Long-Running Observers for remediation. -
Audit CLS. In the Performance tab, record a scroll session and inspect Layout Shift events. Every shift during lazy loading indicates a placeholder without explicit
width/heightoraspect-ratio. Fix: add both attributes to the HTML element. -
Detect duplicate observers. In the Console, monkey-patch
IntersectionObservertemporarily:const _IO = window.IntersectionObserver; window.IntersectionObserver = function(...args) { console.trace('IntersectionObserver created'); return new _IO(...args); };More than one creation per route mount indicates per-element observer instantiation — consolidate to a shared instance.
-
Confirm
unobserve()fires after load. Set a breakpoint insideswapDataAttributes. Step through to verifyobserver.unobserve(target)is reached. If the element re-triggers the callback, theunobservecall is missing or running on the wrong reference. -
Test above-fold LCP images. Confirm that your LCP image has a live
srcattribute in the HTML (notdata-src) andfetchpriority="high". Run Lighthouse and verifylargest-contentful-paintpasses your budget. If lazy loading degraded LCP, check the HTML source —data-srcis the culprit.
FAQ
Does native loading="lazy" replace IntersectionObserver for images?
Native loading="lazy" handles simple deferral without JavaScript. However, it gives you no control over rootMargin, fetch priority, threshold-based two-phase triggers, CSS animation coordination, or analytics callbacks. Use native loading="lazy" as a baseline and as a progressive-enhancement fallback in your data-src markup. Use IntersectionObserver whenever you need programmatic control over the loading sequence.
What rootMargin should I use for lazy loading?
"200px 0px" is the standard starting point for images on desktop connections. For large video assets on fast connections, go up to "400px 0px". For mobile or data-saver contexts, reduce to "50px 0px" or detect navigator.connection.saveData === true and set rootMargin dynamically. The IntersectionObserver API deep dive covers how rootMargin interacts with the root bounding rectangle in detail.
Why does my lazy loader cause layout shift?
Layout shift occurs when the browser does not know the final dimensions of a placeholder before the image loads. Fix: add explicit width and height attributes to every <img> tag — these allow the browser to reserve intrinsic space. Alternatively, apply aspect-ratio: 16/9 via CSS. Never rely on the image itself to communicate its dimensions, because the swap from data-src to src happens asynchronously.
How do I handle lazy loading in an SSR / hydration environment?
Guard IntersectionObserver instantiation behind typeof window !== 'undefined'. In React, all observer creation must live inside useEffect, which runs only on the client. In Vue 3, use onMounted. In Angular, use ngOnInit (Angular's universal build disables IntersectionObserver on the server automatically, but guard defensively). Return the no-op fallback path — immediate src assignment — when the guard fails, so SSR-rendered images are always visible without JavaScript.
When should I call unobserve() vs disconnect()?
Call unobserve(element) immediately after triggering the media load for that specific element. This removes only that target from the observer's internal tracking queue, keeping the shared observer alive for remaining elements. Call disconnect() when the entire observer instance is no longer needed — typically on component unmount, route change, or application teardown. After disconnect(), the observer's queue is empty; do not call observe() on a disconnected observer unless you re-instantiate it.
Related
- Building a lazy image loader with IntersectionObserver
- Dynamic Visibility Tracking
- Infinite Scroll & Pagination
- Observer Lifecycle & Memory Management
- IntersectionObserver Threshold in Practice
↑ Back to Implementation Patterns for Viewport & Resize Tracking