Angular's own structural directives can conditionally render an <img>, but they cannot stop the browser's preloader from fetching a URL the moment it appears anywhere in the rendered HTML — which is why lazy loading needs an IntersectionObserver-driven directive rather than a template conditional. This guide builds [appLazyLoad], a standalone directive that keeps the real image URL out of src until the element is genuinely about to enter the viewport.

Problem / Scenario Context

A typical product grid or article feed renders far more <img> tags than a user will ever scroll to see in a session. Rendering every one of them with a real src attribute means the browser's HTML preloader queues every network request the instant Angular inserts the markup — regardless of whether the element is on screen, one viewport away, or fifty. On a mobile connection this can mean megabytes of wasted transfer and a Largest Contentful Paint delayed by requests that have nothing to do with what the user is actually looking at.

The fix already exists in plain JavaScript form: this site's general lazy-loading guide covers the data-src swap pattern, rootMargin pre-fetch buffers, and cleanup discipline independent of any framework. The Angular-specific problem is wiring that same pattern into a directive that respects NgZone, tears down correctly across route changes, and works whether the image sits inside an @for block, a *ngFor, or a static template.

Mechanics Explanation

The directive's job is narrow and mechanical: read a data-src (and optionally data-srcset) attribute off its host <img>, register the host with a shared IntersectionObserver, and on the first intersection, copy the deferred URL into the real src attribute before calling unobserve() on that one element. Everything else — the placeholder image, the fade-in transition, the grid layout — stays in CSS and the surrounding template, untouched by the directive.

Sharing a single IntersectionObserver instance across every image in a list, rather than constructing one per directive instance, matters here more than in most observer use cases: a list of 200 product cards constructing 200 separate native observers is measurably heavier than one observer watching 200 targets, because the browser's intersection geometry computation and the IntersectionObserver deep dive's batching guarantee both scale with observer count, not just target count. An injectable Angular service is the natural place to hold that shared instance.

Design choice Why
Shared IntersectionObserver via an injectable service One native observer, many targets — avoids per-card observer overhead in large grids
data-src attribute, not conditional src binding Prevents the browser's HTML preloader from fetching before the directive decides to
rootMargin: '200px 0px' Starts the fetch slightly before the image enters the viewport, hiding network latency behind scroll
unobserve() per element, not disconnect() Keeps the shared observer alive for every other image still waiting in the same list
runOutsideAngular() around observe() Prevents 200 initial "not yet visible" callbacks from each scheduling a change-detection pass

Minimal Reproducible Example

The directive below is deliberately minimal — no placeholder handling, no srcset, just the intersect-then-swap mechanic — so the failure modes are easy to isolate before layering in production concerns.

TypeScript
// lazy-load.directive.ts (minimal version)
import { Directive, ElementRef, NgZone, OnDestroy, OnInit } from '@angular/core';

@Directive({
  selector: 'img[appLazyLoad]',
  standalone: true,
})
export class LazyLoadDirective implements OnInit, OnDestroy {
  private observer: IntersectionObserver | null = null;

  constructor(
    private readonly el: ElementRef<HTMLImageElement>,
    private readonly ngZone: NgZone,
  ) {}

  ngOnInit(): void {
    const img = this.el.nativeElement;
    const src = img.dataset['src'];
    if (!src) return;

    if (typeof IntersectionObserver === 'undefined') {
      img.src = src; // no observer support — load immediately
      return;
    }

    this.ngZone.runOutsideAngular(() => {
      this.observer = new IntersectionObserver(
        (entries) => {
          const [entry] = entries;
          if (!entry.isIntersecting) return;
          img.src = src;
          this.observer?.unobserve(img);
        },
        { rootMargin: '200px 0px', threshold: 0 },
      );
      this.observer.observe(img);
    });
  }

  ngOnDestroy(): void {
    this.observer?.disconnect();
    this.observer = null;
  }
}
JavaScript
// Plain JS equivalent, outside any framework
function lazyLoad(img) {
  const src = img.dataset.src;
  if (!src) return;
  if (!('IntersectionObserver' in window)) {
    img.src = src;
    return;
  }
  const observer = new IntersectionObserver((entries) => {
    const [entry] = entries;
    if (!entry.isIntersecting) return;
    img.src = src;
    observer.unobserve(img);
  }, { rootMargin: '200px 0px', threshold: 0 });
  observer.observe(img);
}

This version works correctly for a handful of images, but at list scale it constructs one native IntersectionObserver per <img> — the exact inefficiency the shared-service design in the next section avoids.

Production-Safe Solution

The production version splits responsibilities: an injectable LazyLoadObserverService owns exactly one IntersectionObserver and a WeakMap from each observed image to its load callback; the directive itself becomes a thin registration wrapper.

TypeScript
// lazy-load-observer.service.ts
import { Injectable, NgZone, OnDestroy } from '@angular/core';

type LoadCallback = () => void;

@Injectable({ providedIn: 'root' })
export class LazyLoadObserverService implements OnDestroy {
  private readonly callbacks = new WeakMap<Element, LoadCallback>();
  private observer: IntersectionObserver | null = null;

  constructor(private readonly ngZone: NgZone) {
    if (typeof IntersectionObserver === 'undefined') return;

    this.ngZone.runOutsideAngular(() => {
      this.observer = new IntersectionObserver(
        (entries) => this.handleEntries(entries),
        { rootMargin: '200px 0px', threshold: 0 },
      );
    });
  }

  register(target: Element, onLoad: LoadCallback): void {
    if (!this.observer) {
      onLoad(); // no IntersectionObserver support — load immediately
      return;
    }
    this.callbacks.set(target, onLoad);
    this.observer.observe(target);
  }

  unregister(target: Element): void {
    this.observer?.unobserve(target);
    this.callbacks.delete(target);
  }

  private handleEntries(entries: IntersectionObserverEntry[]): void {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      const onLoad = this.callbacks.get(entry.target);
      onLoad?.();
      this.observer?.unobserve(entry.target);
      this.callbacks.delete(entry.target);
    }
  }

  ngOnDestroy(): void {
    this.observer?.disconnect();
    this.observer = null;
  }
}
TypeScript
// lazy-load.directive.ts (production version)
import { Directive, ElementRef, Inject, OnDestroy, OnInit } from '@angular/core';
import { LazyLoadObserverService } from './lazy-load-observer.service';

@Directive({
  selector: 'img[appLazyLoad]',
  standalone: true,
})
export class LazyLoadDirective implements OnInit, OnDestroy {
  constructor(
    private readonly el: ElementRef<HTMLImageElement>,
    private readonly lazyLoadService: LazyLoadObserverService,
  ) {}

  ngOnInit(): void {
    const img = this.el.nativeElement;
    const src = img.dataset['src'];
    const srcset = img.dataset['srcset'];
    if (!src) return;

    this.lazyLoadService.register(img, () => {
      if (srcset) img.srcset = srcset;
      img.src = src;
      img.removeAttribute('data-src');
      if (srcset) img.removeAttribute('data-srcset');
    });
  }

  ngOnDestroy(): void {
    this.lazyLoadService.unregister(this.el.nativeElement);
  }
}

Template usage, including a base64 low-quality placeholder that the directive replaces on load:

HTML
<img
  appLazyLoad
  src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7"
  [attr.data-src]="product.imageUrl"
  [attr.data-srcset]="product.imageSrcset"
  alt="{{ product.name }}"
  width="400"
  height="300"
  loading="lazy"
/>

Two details make this template safe rather than merely functional: the width/height attributes reserve layout space before either the placeholder or the real image paints, preventing a cumulative layout shift when the swap happens, and the native loading="lazy" attribute is left in place as a second line of defense — browsers that support it will further defer the placeholder's own negligible request, and it costs nothing to leave both mechanisms active simultaneously.

Verification Steps

  • Confirm zero eager requests. Open DevTools → Network, filter by image type, reload a page with 50+ appLazyLoad images below the fold. Only images within the 200px rootMargin buffer of the initial viewport should appear in the request list.
  • Scroll and watch requests stream in. As you scroll, new image requests should appear roughly 200px before each image enters view — confirm the timing lines up with the configured rootMargin, not later.
  • Verify single observer in the Performance profiler. Record a scroll session in Chrome DevTools Performance panel; there should be one IntersectionObserver callback invocation per frame with new entries, not one per image element.
  • Force the fallback path. Temporarily delete window.IntersectionObserver in the console (delete window.IntersectionObserver) and reload — every image should load immediately with no lazy behavior, confirming the fallback in register() triggers correctly.
  • Confirm teardown. Navigate away from the list route and back several times while watching a heap snapshot, per the observer lifecycle and memory management verification workflow — retained size should not grow across cycles, since unregister() calls unobserve() for each destroyed directive instance and the shared service itself only disconnects when the providing injector is destroyed.

Common Mistakes to Avoid

  • Binding the real URL to [src] behind an *ngIf instead of using data-src. Angular's own change detection has no influence over the browser's HTML preloader; if the real URL ever touches a src attribute, some browsers will have already started the request.
  • Calling disconnect() in the directive's ngOnDestroy(). This is correct for a single-observer-per-directive design but wrong once you move to the shared-service pattern — it would stop observation for every other image still in the list. Call unobserve() (via unregister()) instead, and reserve disconnect() for the service's own teardown.
  • Skipping width/height attributes. Without reserved dimensions, the layout shift caused by the placeholder-to-real-image swap actively hurts Cumulative Layout Shift scores, even though the lazy-loading logic itself is working exactly as designed.
  • Not testing the no-IntersectionObserver fallback path. It is easy to ship a directive that only has ever been exercised in a modern browser during development; explicitly verifying the fallback avoids blank images in the rare environment where the API is missing.

FAQ

Why use data-src instead of binding [src] directly with an *ngIf?

data-src is inert to the browser's own preloader. If the real image URL is ever present in a src attribute — even one temporarily removed by *ngIf — some browsers' speculative HTML parsers will have already queued the network request before Angular finishes rendering. Keeping the URL in a non-standard data-src attribute guarantees zero network requests happen until the directive explicitly copies the value into src at the moment the image is actually within the rootMargin buffer.

Should the directive call unobserve() or disconnect() after the image loads?

Call unobserve(this.el.nativeElement) on the single element that just loaded, not disconnect(). A shared IntersectionObserver instance is typically injected once per list or grid and watches many images; disconnect() would tear down tracking for every other image still waiting to load. Reserve disconnect() for the shared service's own ngOnDestroy, when the entire list is being torn down.

What should appLazyLoad do if IntersectionObserver is unavailable?

Fall back to loading the image immediately rather than leaving it blank forever. Check typeof IntersectionObserver === 'undefined' in ngOnInit and, if true, copy data-src to src synchronously. This trades away the lazy-loading optimization but preserves the one guarantee that matters more: content must never depend on a feature the runtime does not have.

How do I show a low-quality placeholder before the full image loads?

Set a tiny base64-encoded or heavily compressed placeholder as the initial src attribute (not data-src, which stays reserved for the full-resolution URL), then let the directive swap in the real data-src value on intersection. Pair this with a CSS transition on opacity or a blur filter that clears once the load event fires on the image element, so the swap reads as a deliberate reveal rather than a layout pop.


↑ Back to Angular Observer Directives