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.

Lazy image loader data flow Diagram showing the sequence from DOM mount, observer.observe() call, scroll event triggering intersection callback, unobserve then src swap, to load/error listener cleanup. DOM mount data-src set observer .observe(img) isIntersecting === true unobserve() + WeakSet add img.src = src fetch begins removeEventListener('load' | 'error') then first

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
// 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.

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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, the rootMargin value is too small or the container has overflow: hidden without an explicit root.
  • Memory tab — heap snapshot after route change: Filter by Detached HTMLImageElement. A clean teardown leaves zero detached nodes. Any remaining ones mean disconnect() 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) inside onLoad temporarily. Each image path should log exactly once, never twice.

Common Mistakes to Avoid

  • Setting img.src before calling unobserve(): The gap between assignment and the next animation frame is enough for a second intersection callback to fire, particularly at threshold 0, producing a duplicate request.
  • Using a plain Set instead of WeakSet for loaded tracking: A Set<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 has width: 0 (e.g. inside a hidden tab panel) causes isIntersecting to fire immediately, because a zero-area element technically intersects any root. The getBoundingClientRect().width === 0 check in observe() 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 as root in the constructor options, or the observer will never fire.

↑ Back to Lazy Loading Images & Media