Build a memory-safe, framework-ready lazy image loader by combining IntersectionObserver with a WeakSet guard and synchronous listener cleanup — eliminating the duplicate fetches and detached-DOM leaks that plague naive viewport-trigger patterns.
Problem / Scenario Context
Modern single-page applications render image grids, feed timelines, and dashboard cards that may contain hundreds of <img> elements on first mount. Loading all of them up front hammers bandwidth and inflates Largest Contentful Paint. The standard fix is to defer fetches until each image nears the viewport, but the details of when and how that swap happens determine whether the solution holds up under rapid scrolling, client-side route transitions, and framework re-renders.
This page belongs to the Lazy Loading Images & Media cluster, which covers the full spectrum of IntersectionObserver-based deferral patterns. The specific scenario addressed here is common in SPAs with dynamic media grids: naive implementations start multiplying network requests or leaking memory after repeated navigation because they never call unobserve(), never remove event listeners, and never disconnect the observer on unmount.
Mechanics Explanation
The IntersectionObserver API schedules its callback after layout and before paint, once per rendering frame, regardless of scroll speed. This is its central advantage over scroll listeners: the browser coalesces many scroll events into a single per-frame check. The callback receives an array of IntersectionObserverEntry objects — one per observed element — with isIntersecting set to true when the element's intersection ratio crosses the configured threshold.
The failure mode arises because intersection events can fire more than once for the same element if the element oscillates around the threshold boundary during a fast scroll. If you swap data-src to src without immediately calling unobserve(), a second callback in the same or next frame finds the element still observed and re-triggers the swap — often producing a duplicate GET for the same URL. The browser may coalesce these into one request via HTTP cache, but it may not, especially if the first request is still in-flight.
A secondary failure appears in component frameworks: the observer instance lives outside React or Vue state. When a component unmounts, the DOM nodes are detached, but the observer's internal entry list still holds references to them. V8 cannot garbage-collect those nodes until disconnect() or unobserve() severs the connection. Heap snapshots show these as Detached HTMLImageElement nodes retained by the observer's closure.
Comparison Table
| Approach | Duplicate request risk | Memory leak risk | CLS risk |
|---|---|---|---|
Swap src without unobserve() |
High — callback fires again on oscillation | High — observer retains element refs | Low |
Native loading="lazy" only |
None | None | Medium — browser controls timing |
unobserve() before src swap (this guide) |
None — observer detached before fetch | None — WeakSet + explicit listener removal | Low — placeholder dimensions reserved |
disconnect() on first intersection |
None | None | Low |
Minimal Reproducible Example
The following 25-line snippet isolates the core pattern: store the real URL in data-src, observe, swap, and immediately unobserve. Run this in a browser console against a page that has <img data-src="…"> elements to see the interaction.
// TypeScript — explicit types; see JS comment block below
interface LazyEntry extends IntersectionObserverEntry {
target: HTMLImageElement;
}
const loaded = new WeakSet<HTMLImageElement>();
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
(entries as LazyEntry[]).forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
if (loaded.has(img) || !img.dataset.src) return;
// Unobserve FIRST to prevent duplicate callbacks
loaded.add(img);
observer.unobserve(img);
img.src = img.dataset.src; // triggers network fetch
});
},
{ threshold: 0.1, rootMargin: "50px 0px" }
);
document.querySelectorAll<HTMLImageElement>("img[data-src]")
.forEach((img) => observer.observe(img));
// Plain JS equivalent — remove type annotations and interface declaration
Production-Safe Solution
The class below extends the minimal example with explicit load/error listener removal, a data-fallback attribute for broken images, and a disconnect() method safe to call from any framework's teardown hook.
interface LazyImageLoaderOptions {
threshold?: number;
rootMargin?: string;
}
class LazyImageLoader {
private readonly observer: IntersectionObserver;
private loaded: WeakSet<HTMLImageElement>;
constructor({ threshold = 0.1, rootMargin = "50px 0px" }: LazyImageLoaderOptions = {}) {
this.loaded = new WeakSet();
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{ threshold, rootMargin }
);
}
observe(img: HTMLImageElement): void {
// Guard: skip already-loaded or dimensionless elements
if (!img || this.loaded.has(img)) return;
if (img.getBoundingClientRect().width === 0) return; // display:none guard
this.observer.observe(img);
}
private handleIntersection(entries: IntersectionObserverEntry[]): void {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target as HTMLImageElement;
const src = img.dataset.src;
if (!src || this.loaded.has(img)) return;
// Mark and detach BEFORE the src assignment closes the race window
this.loaded.add(img);
this.observer.unobserve(img);
const cleanup = () => {
img.removeEventListener("load", onLoad);
img.removeEventListener("error", onError);
img.removeAttribute("data-src");
};
const onLoad = () => cleanup();
const onError = () => {
console.warn(`[LazyImageLoader] Failed: ${src}`);
img.src = img.dataset.fallback ?? "";
cleanup();
};
img.addEventListener("load", onLoad, { once: true });
img.addEventListener("error", onError, { once: true });
img.src = src; // network request starts here
});
}
disconnect(): void {
this.observer.disconnect();
this.loaded = new WeakSet(); // release all element references
}
}
/*
// Plain JS version — remove type annotations:
class LazyImageLoader {
constructor({ threshold = 0.1, rootMargin = "50px 0px" } = {}) {
this.loaded = new WeakSet();
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this), { threshold, rootMargin }
);
}
observe(img) { ... }
handleIntersection(entries) { ... }
disconnect() { ... }
}
*/
Framework lifecycle wiring
React — defer instantiation to useEffect so the observer only runs client-side, and return disconnect() as the cleanup function:
import { useEffect, useRef } from "react";
export function useLazyImages(containerRef: React.RefObject<HTMLElement>): void {
useEffect(() => {
const loader = new LazyImageLoader({ rootMargin: "100px 0px" });
const images = containerRef.current?.querySelectorAll<HTMLImageElement>("img[data-src]") ?? [];
images.forEach((img) => loader.observe(img));
return () => loader.disconnect(); // runs on unmount — severs all observer refs
}, [containerRef]);
}
Vue 3 — use onMounted / onUnmounted:
import { onMounted, onUnmounted, type Ref } from "vue";
export function useLazyImages(containerRef: Ref<HTMLElement | null>): void {
let loader: LazyImageLoader | null = null;
onMounted(() => {
loader = new LazyImageLoader();
containerRef.value?.querySelectorAll<HTMLImageElement>("img[data-src]")
.forEach((img) => loader!.observe(img));
});
onUnmounted(() => loader?.disconnect());
}
Angular — initialize in ngAfterViewInit and clean up in ngOnDestroy:
import { Component, ElementRef, AfterViewInit, OnDestroy } from "@angular/core";
@Component({ selector: "app-media-grid", template: `<ng-content></ng-content>` })
export class MediaGridComponent implements AfterViewInit, OnDestroy {
private loader = new LazyImageLoader({ rootMargin: "80px 0px" });
constructor(private el: ElementRef<HTMLElement>) {}
ngAfterViewInit(): void {
this.el.nativeElement.querySelectorAll<HTMLImageElement>("img[data-src]")
.forEach((img) => this.loader.observe(img));
}
ngOnDestroy(): void {
this.loader.disconnect();
}
}
The { once: true } option on the event listeners (used above) is equivalent to registering and then immediately removing the listener in older browser-compatible code — it keeps the cleanup concise without requiring the separate removeEventListener calls, though both approaches produce the same result. For a deeper look at how intersection timing interacts with the rendering pipeline, see Syncing Observer Callbacks with requestAnimationFrame.
Observer lifecycle and memory management covers the broader disconnect() discipline and WeakMap/WeakSet patterns that underpin the approach above.
Verification Steps
After wiring up the loader, confirm correctness in Chrome DevTools:
- Network tab — filter
Img: Scroll rapidly through your image grid. Each image URL should appear exactly once, with no duplicate entries for the same URL. If you see pairs of identical requests,unobserve()is not firing before the second callback. - Network tab — waterfall: With
rootMargin: "100px 0px", the fetch for an image should begin before you visually reach it. If images load only after scrolling onto them, therootMarginvalue is too small or the container hasoverflow: hiddenwithout an explicitroot. - Memory tab — heap snapshot after route change: Filter by
Detached HTMLImageElement. A clean teardown leaves zero detached nodes. Any remaining ones meandisconnect()was not called on unmount. - Performance tab — main thread: Record a 3-second scroll session. Observer callbacks should appear as short tasks (under 5 ms). Long tasks inside the callback indicate DOM mutations or synchronous reads that should be deferred.
- Console: Add
console.log("[lazy] loaded:", img.src)insideonLoadtemporarily. Each image path should log exactly once, never twice.
Common Mistakes to Avoid
- Setting
img.srcbefore callingunobserve(): The gap between assignment and the next animation frame is enough for a second intersection callback to fire, particularly at threshold0, producing a duplicate request. - Using a plain
Setinstead ofWeakSetfor loaded tracking: ASet<HTMLImageElement>holds a strong reference. After a route transition, every image node your loader processed stays in memory until you manually clear the Set — which rarely happens in practice. - Omitting the
display:none/ zero-dimension guard: Observing an element that haswidth: 0(e.g. inside a hidden tab panel) causesisIntersectingto fire immediately, because a zero-area element technically intersects any root. ThegetBoundingClientRect().width === 0check inobserve()prevents premature fetches. - Not accounting for custom scroll containers: When images live inside an element with
overflow: auto, the default root (the document viewport) never intersects them correctly. Pass the scroll container asrootin the constructor options, or the observer will never fire.
Related
- Lazy Loading Images & Media — overview
- Preventing Memory Leaks in Long-Running Observers
- How IntersectionObserver Threshold Works in Practice
- Tracking Ad Visibility for Analytics Compliance
- Creating Smooth Infinite Scroll Without Jank
↑ Back to Lazy Loading Images & Media