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.

Two-phase IntersectionObserver lazy loading flow Diagram showing scroll direction, the 200px prefetch rootMargin zone above the visible viewport, and the viewport boundary where the CSS fade-in transition fires. Three image placeholders advance through states: idle, loading (data-src swapped), and loaded (fade complete). rootMargin: "200px 0px" — PREFETCH ZONE Observer fires here → swap data-src → src, triggering network fetch loading… loading… ▼ VIEWPORT TOP EDGE — threshold: 0.01 → CSS fade-in fires VISIBLE VIEWPORT loaded unobserve() loaded unobserve() loaded unobserve() BELOW FOLD — elements observed, data-src intact, no fetch yet idle idle scroll ↓

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.

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

HTML
<!-- 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:

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

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

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

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

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

  1. Verify rootMargin fires 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, increase rootMargin.

  2. 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 HTMLImageElement nodes indicate a missing disconnect() or unobserve() call. See Preventing Memory Leaks in Long-Running Observers for remediation.

  3. 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/height or aspect-ratio. Fix: add both attributes to the HTML element.

  4. Detect duplicate observers. In the Console, monkey-patch IntersectionObserver temporarily:

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

  5. Confirm unobserve() fires after load. Set a breakpoint inside swapDataAttributes. Step through to verify observer.unobserve(target) is reached. If the element re-triggers the callback, the unobserve call is missing or running on the wrong reference.

  6. Test above-fold LCP images. Confirm that your LCP image has a live src attribute in the HTML (not data-src) and fetchpriority="high". Run Lighthouse and verify largest-contentful-paint passes your budget. If lazy loading degraded LCP, check the HTML source — data-src is 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.


↑ Back to Implementation Patterns for Viewport & Resize Tracking