JavaScript Intersection Observer API: A Deep Practical Guide

deep-diveApril 13, 2026· 6 min read

You've probably written this before:

window.addEventListener('scroll', () => {
  const rect = element.getBoundingClientRect();
  if (rect.top < window.innerHeight) {
    // do something
  }
});

It works. But it fires on every single scroll event — that's 60+ times per second on most devices. Add getBoundingClientRect() triggering layout reflows each time, and you've got a recipe for janky scrolling and poor Lighthouse scores.

The Intersection Observer API was built to solve exactly this problem. It lets you watch elements and get notified only when they actually cross a visibility threshold — no manual scroll handling, no layout thrashing, no performance headaches.

The API

At its core, it's simple:

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      console.log('Element is visible!', entry.target);
    }
  });
}, options);

observer.observe(targetElement);

You create an observer with a callback and some options, then tell it which elements to watch. When those elements enter or leave the viewport (or a custom root), your callback fires.

The Options Object

const options = {
  root: null,        // viewport by default, or a specific element
  rootMargin: '0px', // grow/shrink the root's bounding box
  threshold: 0       // when to trigger (0 = any pixel visible, 1 = 100% visible)
};

root — The element used as the viewport for visibility checking. null means the browser viewport. You can pass any element to observe visibility within a scrollable container.

rootMargin — Margin around the root. Think of it like CSS margins — '100px 0px' means "trigger 100px before the element actually enters the viewport." This is the key to pre-loading content just before it scrolls into view.

threshold — Can be a single number or an array. 0 triggers the moment any part is visible. 1 triggers only when 100% is visible. [0, 0.25, 0.5, 0.75, 1] triggers at each quarter.

// Trigger at 25%, 50%, 75%, and 100% visibility
const observer = new IntersectionObserver(callback, {
  threshold: [0.25, 0.5, 0.75, 1.0]
});

The Entry Object

Each entry in the callback gives you rich data:

{
  isIntersecting: true,     // currently visible?
  intersectionRatio: 0.65,  // 65% visible
  target: element,          // the DOM element
  boundingClientRect: {},   // element's bounds
  intersectionRect: {},     // visible portion's bounds
  rootBounds: {},           // root's bounds
  time: 1234.5              // timestamp when intersection changed
}

Practical Use #1: Lazy Loading Images

This is the most common use case and the one with the biggest performance impact. Instead of loading all images on page load, load them only when they're about to scroll into view.

<img data-src="photo.jpg" alt="A photo" class="lazy" />
const imageObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.classList.remove('lazy');
        imageObserver.unobserve(img); // stop watching — we're done
      }
    });
  },
  { rootMargin: '200px' } // load 200px before visible
);

document.querySelectorAll('img.lazy').forEach((img) => {
  imageObserver.observe(img);
});

The rootMargin: '200px' trick is important — it starts loading images slightly before the user scrolls to them, so by the time they arrive, the image is already there. No blank space, no loading spinners.

Don't forget unobserve() — once the image is loaded, there's no reason to keep watching it. This keeps the observer lightweight.

A Quick Gotcha: SEO and No-JS

Search engines generally execute JavaScript, but for bulletproof SEO, add a <noscript> fallback:

<img data-src="photo.jpg" alt="A photo" class="lazy" />
<noscript>
  <img src="photo.jpg" alt="A photo" />
</noscript>

Practical Use #2: Infinite Scroll

Pagination is fine, but infinite scroll keeps users engaged. Here's a clean pattern:

const sentinel = document.getElementById('sentinel'); // empty div at the bottom

const scrollObserver = new IntersectionObserver(
  async (entries) => {
    if (entries[0].isIntersecting) {
      const posts = await fetchMorePosts();
      renderPosts(posts);

      // If there's a next page, the sentinel stays — observer keeps watching
      // If we're done, remove the sentinel
      if (!posts.hasMore) {
        scrollObserver.unobserve(sentinel);
        sentinel.remove();
      }
    }
  },
  { rootMargin: '400px' }
);

scrollObserver.observe(sentinel);

The "sentinel" pattern — an empty div at the bottom of your content list — is cleaner than observing the last element, because you don't need to re-observe when new elements are added. The sentinel just sits at the bottom.

Why this beats scroll listeners: The browser handles the scroll math internally. Your callback only fires when the sentinel enters the trigger zone. No debounce needed, no requestAnimationFrame hacks.

Practical Use #3: Scroll-Triggered Animations

Reveal elements as they scroll into view — a staple of modern landing pages:

const animateObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.classList.add('animate-in');
        animateObserver.unobserve(entry.target);
      }
    });
  },
  { threshold: 0.15 }
);

document.querySelectorAll('.reveal').forEach((el) => {
  animateObserver.observe(el);
});
.reveal {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}

.reveal.animate-in {
  opacity: 1;
  transform: translateY(0);
}

This approach is better than scroll-based animation libraries because:

  • No JavaScript runs on every frame — the browser handles intersection detection natively
  • CSS transitions handle the animation — GPU-accelerated, no JS animation loop
  • One-shotunobserve after triggering, zero ongoing cost

Staggered Animations

Want children to animate one after another?

const staggerObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const children = entry.target.querySelectorAll('.stagger-child');
        children.forEach((child, i) => {
          child.style.transitionDelay = `${i * 100}ms`;
          child.classList.add('animate-in');
        });
        staggerObserver.unobserve(entry.target);
      }
    });
  },
  { threshold: 0.1 }
);

Performance and Core Web Vitals

Here's why Intersection Observer matters for your Lighthouse score:

Largest Contentful Paint (LCP)

Lazy loading your hero image? Don't. The LCP image should load immediately — lazy load everything below the fold only. A common mistake:

// ❌ Bad — lazy loads the hero image, killing LCP
document.querySelectorAll('img').forEach(img => observer.observe(img));

// ✅ Good — skip the first N images or use loading="eager" on the hero
document.querySelectorAll('img.lazy').forEach(img => observer.observe(img));

Or better yet, use the native loading attribute for images and let the browser handle it:

<img src="hero.jpg" loading="eager" alt="Hero" />  <!-- LCP image -->
<img src="photo2.jpg" loading="lazy" alt="Gallery" /> <!-- below fold -->

Cumulative Layout Shift (CLS)

Lazy-loaded images cause layout shifts if you don't reserve space. Always set dimensions:

<!-- ❌ Causes CLS — browser doesn't know the size until loaded -->
<img data-src="photo.jpg" class="lazy" alt="Photo" />

<!-- ✅ No CLS — space is reserved -->
<img data-src="photo.jpg" class="lazy" alt="Photo" width="800" height="600" />

Or use aspect-ratio in CSS:

.lazy-image {
  aspect-ratio: 16 / 9;
  width: 100%;
  background: #f0f0f0; /* placeholder color */
}

First Input Delay (FID) / Interaction to Next Paint (INP)

Intersection Observer callbacks are microtasks — they don't block the main thread like scroll event handlers do. This directly improves INP because:

  • No getBoundingClientRect() forced layouts
  • No JavaScript running 60 times per second
  • The browser optimizes intersection checks internally (often off main thread)

Observing Multiple Elements Efficiently

One observer can watch many elements — this is more efficient than creating one observer per element:

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      handleVisible(entry.target);
      observer.unobserve(entry.target);
    }
  });
}, { rootMargin: '100px' });

// Observe 500 images with ONE observer
document.querySelectorAll('img.lazy').forEach(img => observer.observe(img));

Disconnecting and Cleanup

When you're done with an observer entirely:

observer.disconnect(); // stops observing ALL elements

Or to stop watching a specific element:

observer.unobserve(element);

Always clean up — observers hold references to DOM elements, preventing garbage collection.

Browser Support

Intersection Observer is supported in all modern browsers (95%+ globally). For IE11 (if you still need it), there's a lightweight polyfill from the W3C.

Quick Reference

What you needObserver config
Lazy load images{ rootMargin: '200px' } + unobserve after load
Infinite scrollSentinel div + { rootMargin: '400px' }
Scroll animations{ threshold: 0.15 } + CSS transitions
Video autoplay in view{ threshold: 0.5 } — play when 50% visible
Sticky header detection{ rootMargin: '-1px 0px 0px 0px', threshold: [1] } on a sentinel at top

Summary

  • Stop using scroll listeners for visibility detection — Intersection Observer does it better, cheaper, and without layout thrashing
  • Use rootMargin to pre-load content before it scrolls into view (lazy loading) or delay triggers until elements are deep in the viewport
  • Always unobserve when you're done with an element — don't leak memory
  • Reserve image dimensions to prevent CLS when lazy loading
  • Don't lazy load your LCP image — it hurts your Core Web Vitals score
  • One observer, many elements — it's designed for it and more performant than multiple observers