JavaScript Intersection Observer: Lazy Loading, Animations and Infinite Scroll
AI generated
JS
() =>
JavaScript · Intersection Observer · Lazy Loading · Web Performance
JavaScript Intersection Observer
Lazy Loading, Animations and Infinite Scroll

Scroll event listeners for visibility checks are outdated and expensive. The Intersection Observer delivers viewport notifications without blocking the main thread, without layout thrashing, and with configurable thresholds, the modern solution for lazy loading, scroll animations and infinite scroll.

14 min read Lazy loading · Scroll animations · Infinite scroll · Analytics Browser · React · Alpine.js · Vanilla JS

1. Why scroll events are not a good foundation

The classic approach to visibility checks is a scroll event listener that calls getBoundingClientRect() on elements on every scroll tick. That sounds simple, but it has serious performance problems. getBoundingClientRect() forces a layout reflow: the browser has to compute all pending style changes before it can return the geometry. With one scroll event per pixel, at 60fps scrolling that means up to 60 layout reflows per second. The result is stutter, dropped frames and a UI that lags behind the scroll.

The Intersection Observer solves this problem in a fundamentally different way: instead of actively polling, you register elements with the observer and are passively notified when their visibility changes. The calculations happen inside the browser internals, not on the JavaScript main thread, and they are synchronized with the rendering cycle. The result: zero layout reflows caused by JavaScript, no main thread blocking, and notifications that are guaranteed to sit outside the critical rendering path. For any use case that needs to know whether an element is in the viewport, the Intersection Observer is the superior solution compared to scroll event polling.

2. The Intersection Observer API: core concepts

An Intersection Observer is created with a callback and an optional configuration. The callback is invoked as soon as observed elements cross a configured visibility threshold, either when entering the viewport or when leaving it. The callback receives an array of IntersectionObserverEntry objects, which contain useful properties: isIntersecting (boolean visibility state), intersectionRatio (share of the visible element, 0 to 1), boundingClientRect (element position) and time (event timestamp).

Elements are registered with observer.observe(element) and deregistered with observer.unobserve(element). When an element no longer needs to be observed, after an image has loaded or after a component unmounts, unobserve() is important to avoid memory leaks. observer.disconnect() terminates the entire observer and deregisters all observed elements. In React components, disconnect() belongs in the cleanup return of useEffect. A single Intersection Observer can observe any number of elements, more efficient than a separate observer per element.


// Basic Intersection Observer setup, one observer for many elements
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    const el = entry.target;

    if (entry.isIntersecting) {
      // Element entered viewport
      console.log(`${el.id} is ${Math.round(entry.intersectionRatio * 100)}% visible`);
    } else {
      // Element left viewport
      console.log(`${el.id} left the viewport`);
    }
  });
}, {
  root: null,           // null = browser viewport
  rootMargin: '0px',    // No expansion of the root's bounding box
  threshold: [0, 0.5, 1], // Notify at 0%, 50% and 100% visibility
});

// Observe multiple elements with one observer (efficient)
document.querySelectorAll('[data-observe]').forEach(el => {
  observer.observe(el);
});

// Cleanup, always disconnect when no longer needed
// In React: return () => observer.disconnect() inside useEffect
function cleanup() {
  observer.disconnect();
}

3. Threshold and rootMargin: precise visibility control

The two most important configuration options of the Intersection Observer are threshold and rootMargin. threshold defines at what fraction of visibility the callback fires. A threshold of 0 fires as soon as a single pixel of the element is in the viewport. A threshold of 1 only fires when the element is fully visible. An array of thresholds such as [0, 0.25, 0.5, 0.75, 1] enables granular progress tracking, the core of viewport-based analytics.

rootMargin expands or shrinks the detection area of the root rectangle, similar to CSS margins. A positive rootMargin of '200px 0px' means: the Intersection Observer reports an element as visible when it is 200 pixels outside the viewport, 200 pixels before it actually becomes visible. This is the most important feature for lazy loading: images load before the user scrolls to them, so they are already loaded once they enter the visible area. A negative rootMargin shrinks the detection area, useful for making sure an element is truly fully in the viewport, not just visible by a single pixel.

4. Lazy loading images: optimal for performance

Lazy loading images is the most common use case for the Intersection Observer. The principle: images are initially rendered without a src attribute (using a data-src attribute instead), and only when they approach the viewport is the real image loaded. That saves initial download time, reduces the data transfer for users who never scroll to the bottom of the page, and improves the LCP score and other Core Web Vitals.

For modern browsers there is the native loading="lazy" attribute, which does the same thing without JavaScript. The Intersection Observer approach offers more control, though: you can configure the preload radius with rootMargin, choose between different image qualities based on network speed, implement blur-up effects (show a thumbnail first, then the high-resolution image) and also lazy load non-native elements such as CSS background images, something the loading attribute does not support. The Intersection Observer pattern of calling observer.unobserve(entry.target) right after the image loads is essential here: once an image is loaded, it no longer needs to be observed.


// Lazy image loading with preload margin and blur-up effect
function initLazyImages() {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;

      const img = entry.target;
      const src = img.dataset.src;
      const srcset = img.dataset.srcset;

      if (!src) return;

      // Load the real image, apply srcset if available
      const tempImage = new Image();
      tempImage.onload = () => {
        img.src = src;
        if (srcset) img.srcset = srcset;

        // Remove blur-up placeholder class once loaded
        img.classList.remove('blur-placeholder');
        img.classList.add('loaded');

        // Stop observing, image is loaded
        observer.unobserve(img);
      };
      tempImage.src = src;
    });
  }, {
    root: null,
    rootMargin: '300px 0px', // Start loading 300px before viewport entry
    threshold: 0,
  });

  document.querySelectorAll('img[data-src]').forEach(img => {
    observer.observe(img);
  });

  return observer;
}

// Usage in HTML:
// <img data-src="/images/product.jpg" data-srcset="/images/product-2x.jpg 2x"
//      src="/images/product-thumb.jpg" class="blur-placeholder" alt="Product">

// CSS for blur-up effect:
// .blur-placeholder { filter: blur(8px); transition: filter 0.3s; }
// .loaded { filter: none; }

const lazyLoader = initLazyImages();
// When unmounting: lazyLoader.disconnect()

5. Scroll animations: animating elements as they appear

Scroll animations, elements that fade in, slide up or change color as they enter the viewport, are elegant and performant to implement with the Intersection Observer. The pattern: elements initially receive a CSS class that positions them as invisible or offset. The Intersection Observer adds a second class when the element enters the viewport, and the CSS transition handles the animation. All the animation work happens in the CSS rendering thread, not on the JavaScript main thread.

One important detail for scroll animations: the will-change: transform, opacity CSS property on animated elements instructs the browser to prepare a separate compositor layer. That makes the animation smoother because it runs entirely on the GPU compositor thread. It's also important to consider users with vestibular disorders: the CSS media query prefers-reduced-motion should always be respected to disable or reduce animations. The Intersection Observer callback can check window.matchMedia('(prefers-reduced-motion: reduce)').matches before setting animation classes.


// Scroll-triggered CSS animations with prefers-reduced-motion support
function initScrollAnimations() {
  // Respect user's motion preference
  const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  if (prefersReducedMotion) {
    // Show all elements immediately without animation
    document.querySelectorAll('[data-animate]').forEach(el => {
      el.classList.add('animate-visible');
    });
    return null;
  }

  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const el = entry.target;
        const delay = el.dataset.animateDelay ?? '0ms';

        el.style.transitionDelay = delay;
        el.classList.add('animate-visible');

        // Once animated in, stop observing (avoids re-triggering on scroll back)
        observer.unobserve(el);
      }
    });
  }, {
    root: null,
    rootMargin: '0px 0px -80px 0px', // Trigger 80px before bottom of viewport
    threshold: 0.1,
  });

  document.querySelectorAll('[data-animate]').forEach(el => {
    observer.observe(el);
  });

  return observer;
}

// CSS for the animation:
// [data-animate] { opacity: 0; transform: translateY(24px); transition: opacity 0.5s, transform 0.5s; }
// [data-animate].animate-visible { opacity: 1; transform: none; }

initScrollAnimations();

6. Infinite scroll: automatically loading new content

Infinite scroll is one of the most common UX patterns for content feeds, product listings and social media. With the Intersection Observer it can be implemented cleanly: you place an invisible "sentinel" element at the end of the list and observe it. When the sentinel becomes visible, you load the next page and append the new elements before the sentinel. The sentinel stays at the end and triggers the next page on the next scroll.

Important details for infinite scroll with the Intersection Observer: you need to disable the observer while loading, or set a flag, to prevent parallel requests. When no further pages are available, the sentinel is deregistered with observer.unobserve(sentinel). Error handling with a retry button significantly improves the UX during network failures. For accessibility, it's important to update an ARIA live region after loading new content, so screen reader users are notified. The Intersection Observer approach is considerably more accessible here than imperative scroll event handlers, because the trigger logic is cleanly separated from the loading logic.

7. Sticky header and sentinel pattern

The sentinel pattern with the Intersection Observer also solves a typical CSS problem: you don't know whether a position: sticky element is currently "stuck" or still in the normal document flow. CSS has no pseudo-class for "is sticky". With the Intersection Observer this can be solved elegantly: you place an invisible sentinel element directly before the sticky element. When the sentinel leaves the viewport (scrolls out at the top), you know the sticky element is now stuck.

This pattern is used to give the sticky header a different look once it sticks, a shadow, a different background color or a more compact height. Without the Intersection Observer, this is solved with a scroll event listener that checks the position on every scroll tick, expensive and error-prone. The Intersection Observer approach is precise: the callback is called exactly once when the sentinel crosses the boundary, and it happens outside the critical rendering path. Another use case for the sentinel pattern: detecting the start and end of scrolling for analytics, or for showing a "back to top" button.

8. Viewport analytics: what users actually see

Classic page view analytics know that a page was loaded, but not whether the user actually scrolled to a particular section. With the Intersection Observer and an array of thresholds, you can measure how far users scroll down a page, which sections they really see, and how long particular elements were visible in the viewport. These are valuable metrics for content optimization and A/B testing.

For viewability measurement, the IAB standard states that an ad counts as "viewable" if at least 50% of its area is in the viewport for at least one second, you need an Intersection Observer with a threshold of 0.5 and a timer that starts when the element becomes 50% visible and stops when it falls below that. This pattern is the foundation for programmatic ad impressions and for measuring actual content consumption, not just page loads. The intersectionRatio property of the entry object provides the exact share of the visible element at each trigger point.

9. Intersection Observer vs. scroll events compared

The performance differences between the Intersection Observer and scroll event based approaches are measurable and substantial in practice. Scroll events fire at high frequency and force synchronous layout calculations. The Intersection Observer fires asynchronously, whenever the browser considers it optimal, and returns geometry that is already computed.

Criterion Scroll event + getBoundingClientRect Intersection Observer
Layout thrashing Yes, on every scroll tick No, computed inside the browser
Main thread load High, JS runs per scroll event Minimal, async, batched
Multiple elements O(n) per scroll event One observer for all elements
Nested scroll containers Multiple event listeners needed root parameter for custom containers
Threshold detection Implement manually Native, threshold array
Preload margin Calculate manually as pixel offset rootMargin, CSS syntax

A practical example from performance measurement: a page with 50 images that are lazy loaded via scroll events typically incurs 5 to 15 ms of scripting cost per scroll frame during fast scrolling. With the Intersection Observer this drops below 0.5 ms, because no JavaScript sits in the critical scroll path. For users on mid-range devices, that makes the difference between smooth 60fps scrolling and visible jank. The Intersection Observer has been available since Chrome 51, Firefox 55 and Safari 12.1, and today it's natively available essentially everywhere without needing a polyfill.

Mironsoft

Web performance, frontend architecture and Core Web Vitals optimization

Want to improve Core Web Vitals and optimize scroll performance?

We analyze your site for layout thrashing, scroll event overhead and lazy loading gaps, and replace them with Intersection Observer based implementations for measurably better LCP and CLS scores.

Performance audit

Identification of scroll event overhead, layout thrashing and missing lazy loading strategies

Lazy loading

Intersection Observer based image lazy loading with blur-up effect and rootMargin preload

Core Web Vitals

Improving LCP, CLS and INP through optimized lazy loading, scroll animations and resource prioritization

10. Summary

The Intersection Observer is the modern solution for any task that needs to know whether an element is visible in the viewport. It replaces scroll event listeners, which cause layout thrashing and load the main thread, with asynchronous, browser-optimized notifications. The threshold array enables granular visibility tracking. rootMargin enables preload margins for lazy loading and trigger offsets for animations. A single observer can efficiently watch all elements on a page.

The most important use cases: lazy loading images and videos with a preload margin. Scroll animations that set CSS classes on viewport entry, respecting prefers-reduced-motion. Infinite scroll with a sentinel element and no polling. Sticky header status detection via the sentinel pattern. Viewport analytics for viewability measurement and content tracking. In all these cases, the Intersection Observer is not only more ergonomic than scroll events, but measurably more performant, a direct contribution to better Core Web Vitals and a smoother user experience.

Intersection Observer, the essentials at a glance

No layout thrashing

Intersection Observer computes geometry inside the browser, not via JS polling. Zero forced layout reflows, smooth scrolling even with many observed elements.

rootMargin for preloading

rootMargin: '300px 0px' lets images load 300px before viewport entry. Already loaded by the time they're visible, better LCP with no extra requests.

unobserve after action

Always call observer.unobserve(entry.target) after one-time actions (loading an image, playing an animation). Prevents memory leaks and unnecessary re-triggers.

prefers-reduced-motion

Only run scroll animations when matchMedia('(prefers-reduced-motion: reduce)').matches === false. Accessibility takes priority over aesthetics.

11. FAQ: JavaScript Intersection Observer

1Why is Intersection Observer faster than scroll events?
Scroll events force layout reflows via getBoundingClientRect(). Intersection Observer computes inside the browser, asynchronously and outside the critical rendering path, no JS runs while scrolling.
2What is rootMargin for lazy loading?
rootMargin: '300px 0px', marks elements as visible 300px before viewport entry. Images finish loading before the user sees them, better LCP.
3When to call unobserve()?
After one-time actions: loading an image, playing an animation. observer.unobserve(entry.target) inside the callback. On unmount: observer.disconnect().
4Multiple elements with one observer?
Yes, the recommended approach. One observer for all elements of the same type. The callback receives an array of all entries that changed at the same time.
5Making scroll animations accessible?
Check matchMedia('(prefers-reduced-motion: reduce)').matches. If true: show all elements immediately without animation. Accessibility takes priority.
6Sentinel pattern for sticky headers?
Observe an invisible element directly before the sticky header. When the sentinel leaves the viewport, the header is stuck. Set the 'is-stuck' class for visual feedback.
7Implementing viewability measurement?
threshold: 0.5, start a timer at intersectionRatio >= 0.5. Stop the timer below 0.5. IAB standard: 50% of the element visible for at least 1 second equals a viewable impression.
8Avoiding duplicate requests with infinite scroll?
Set an isLoading flag before the request. The callback checks if (isLoading) return. Reset it once complete. Alternatively: unobserve() the sentinel while loading, then re-observe.
9Browser compatibility?
Chrome 51+, Firefox 55+, Safari 12.1+, Edge (Chromium), usable today without a polyfill in practice. Safari below 12.1 needs a polyfill, barely relevant anymore.
10Non-viewport scroll containers?
root: document.querySelector('.container'), observe visibility relative to that container. Ideal for carousels, scrollable lists and modal dialogs.