Skip to content
DOM Observers: Lazy Loading and Infinite Scroll
May 28, 2026· 10 min read
PerformanceIntersectionObserverDOM

DOM Observers: Lazy Loading and Infinite Scroll

Use IntersectionObserver, ResizeObserver, and MutationObserver for lazy images, infinite scroll, and fast responsive UI.

Scroll handlers that run on every pixel waste frames and battery. Browser observers solve this by notifying your code only when something relevant actually changes.

This guide focuses on the three observers used most in production: IntersectionObserver for visibility, ResizeObserver for element size, and MutationObserver concepts for DOM changes.

What IntersectionObserver does

It watches whether an element enters the viewport. The browser batches these checks off the main thread, which makes it far cheaper than a scroll listener.

A rootMargin of 200px starts loading slightly before the user arrives, hiding network latency on slower connections.

How to lazy load images for performance

Store the real URL in data-src with a light placeholder in src. Swap it when the image approaches the viewport, then stop observing it.

javascript
function lazyLoadImages(root = document) {
  const images = root.querySelectorAll("img[data-src]");
  if (!("IntersectionObserver" in window)) {
    images.forEach((img) => {
      img.src = img.dataset.src;
    });
    return;
  }
  const io = new IntersectionObserver(
    (entries, observer) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue;
        const img = entry.target;
        img.src = img.dataset.src;
        img.removeAttribute("data-src");
        observer.unobserve(img);
      }
    },
    { rootMargin: "200px 0px", threshold: 0.01 }
  );
  images.forEach((img) => io.observe(img));
  return () => io.disconnect();
}

Native lazy loading or observer?

Native loading="lazy" is enough for simple pages. Choose IntersectionObserver when you need blur-up transitions, priority prefetching, or view analytics.

How to wait for visibility with a promise

One-shot visibility fits naturally into async flows. Resolve once, disconnect immediately, and let calling code continue.

javascript
function whenVisible(el, options) {
  return new Promise((resolve) => {
    const io = new IntersectionObserver((entries) => {
      if (entries.some((e) => e.isIntersecting)) {
        io.disconnect();
        resolve(true);
      }
    }, options);
    io.observe(el);
  });
}

const hero = document.querySelector("#hero");
await whenVisible(hero, { threshold: 0.3 });
hero.classList.add("is-visible");

How to autoplay video and reveal content on scroll

Videos should play only while visible. This saves bandwidth and battery, especially on mobile feeds with multiple players.

javascript
function autoPlayVideo(video) {
  const io = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting) video.play().catch(() => {});
      else video.pause();
    },
    { threshold: 0.5 }
  );
  io.observe(video);
  return () => io.disconnect();
}

function revealOnScroll(selector = ".reveal") {
  const els = document.querySelectorAll(selector);
  const io = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          entry.target.classList.add("is-visible");
          io.unobserve(entry.target);
        }
      }
    },
    { threshold: 0.15 }
  );
  els.forEach((el) => io.observe(el));
}

The same visibility signal drives scroll-reveal animations. Adding a class once and unobserving keeps the animation cheap and idempotent.

How to build reliable infinite scroll

Place an empty sentinel element after the list. When it becomes visible, load the next page and append the results.

javascript
let page = 1;
let loading = false;
let done = false;

async function initInfiniteScroll(sentinel, list) {
  const io = new IntersectionObserver(async ([entry]) => {
    if (!entry.isIntersecting || loading || done) return;
    loading = true;
    try {
      const items = await fetchPage(page + 1);
      if (items.length === 0) {
        done = true;
        io.disconnect();
        return;
      }
      page += 1;
      list.append(...items.map(renderItem));
    } finally {
      loading = false;
    }
  });
  io.observe(sentinel);
}

Why loading and done flags are required

Visibility can fire repeatedly while a request is in flight. The loading flag prevents duplicate pages, and the done flag disconnects the observer when the API is exhausted.

How to adapt UI with ResizeObserver

Unlike media queries, ResizeObserver reacts to the element's own width. Components inside sidebars, cards, and resizable panels can adapt their own labels and layouts.

javascript
function responsiveLabel(el, labels) {
  const ro = new ResizeObserver(([entry]) => {
    el.textContent =
      entry.contentRect.width < 320 ? labels.compact : labels.full;
  });
  ro.observe(el);
  return () => ro.disconnect();
}

Real-world use cases

  • E-commerce: lazy product images with early prefetch margins.
  • Media feeds: auto-play and pause videos based on visibility.
  • Dashboards: infinite activity logs with guarded pagination.

Best practices for observer code

  • Unobserve one-shot targets and disconnect observers on cleanup.
  • Prefetch with rootMargin instead of waiting for exact visibility.
  • Keep thresholds small for loading and higher for play or reveal decisions.
  • Provide a no-observer fallback so older browsers still show content.

Common mistakes to avoid

  • Using scroll listeners where an observer would batch the work.
  • Forgetting loading guards in infinite scroll and duplicating pages.
  • Observing hundreds of elements without unobserving completed ones.

Frequently asked questions

Is IntersectionObserver supported everywhere?

Support is broad in modern browsers. Include a simple fallback that loads content immediately when the API is unavailable.

Does observing hurt performance?

Far less than scroll handlers. The browser batches visibility checks, and unobserving finished targets keeps the watched set small.

Key takeaways

  • Use IntersectionObserver for visibility work and unobserve when done.
  • Guard infinite scroll with loading and completion flags.
  • Prefer ResizeObserver for component-level responsive behavior.