Guide

Intersection Observer API explained

Harbor Commerce’s category page rendered 240 product cards on first load, each with a full-resolution src attribute. The browser opened 240 concurrent image requests before the user scrolled; LCP stalled at 4.1 s and CLS spiked as late-loading thumbnails reshuffled the grid. Replacing eager loads with Intersection Observer — swapping data-src to src only when a card crossed a 200 px prefetch margin — cut initial network bytes by 78% and improved LCP to 2.3 s without attaching a scroll listener that ran on every frame. The Intersection Observer API is a browser-native way to ask “when does this element intersect a target region?” and receive asynchronous callbacks when the answer changes. It powers lazy loading, infinite scroll, scroll-triggered animations, analytics viewability, and sticky-header state toggles — all without the jank and battery drain of manual getBoundingClientRect() polling. This guide covers observer configuration (root, rootMargin, threshold), lazy media loading, infinite list fetch triggers, ad and analytics viewability, animation patterns with prefers-reduced-motion, React integration, the Harbor Commerce catalog refactor, a technique decision table, pitfalls, and a production checklist.

Why scroll listeners are the wrong default

Before Intersection Observer shipped in 2019, developers detected viewport entry with window.addEventListener('scroll', ...) and element.getBoundingClientRect(). That pattern has three problems:

  • Main-thread cost — scroll events fire frequently during kinetic scrolling on mobile. Each handler that reads layout forces synchronous reflow if you measure geometry.
  • Missed updates — throttling scroll handlers to 16 ms still misses sub-frame intersections; debouncing delays lazy loads until scroll stops, producing visible pop-in.
  • Composition with other scroll logic — sticky navs, parallax layers, and analytics viewability each add another listener. The handlers compete for the same frame budget and hurt INP.

Intersection Observer moves intersection calculations off the hot scroll path. The browser batches geometry checks and delivers a callback only when an observed element’s intersection ratio crosses a configured threshold. Your JavaScript runs once per state change, not once per scroll event.

Observer configuration: root, rootMargin, threshold

An observer is created with new IntersectionObserver(callback, options) and attached to targets via observer.observe(element). Three options define the intersection region:

root

The element whose bounds form the intersection box. Default is the viewport (null). Set root to a scrollable container when observing inside a nested panel, modal, or virtualized list — otherwise the observer uses the window and misses elements that scroll inside a div.

rootMargin

CSS-like margin syntax that expands or shrinks the root before testing intersection. rootMargin: '200px 0px' triggers 200 px before the element enters the viewport — ideal for prefetching images so decode completes before the user sees blank placeholders. Negative margins shrink the effective viewport for “fully visible” viewability checks.

threshold

A number or array between 0 and 1 representing the fraction of the target visible inside the root. 0 fires when any pixel appears; 1 fires only when fully visible; [0, 0.5, 1] fires at each crossing. Ad viewability standards often require 50% visible for one second — combine threshold 0.5 with a timer in the callback.

Each callback receives entries with isIntersecting, intersectionRatio, boundingClientRect, and time. Always iterate all entries; a single callback may batch multiple targets.

Lazy loading images and media

Native loading="lazy" on <img> uses Intersection Observer internally for below-the-fold images. It is sufficient for simple content sites and requires no JavaScript. Use a custom observer when you need:

  • Prefetch margin control — native lazy load starts near the viewport edge; rootMargin lets you start fetches earlier on slow connections.
  • Background images and CSS — native lazy applies to <img> and <iframe>, not background-image on divs.
  • Conditional quality tiers — swap data-src to a WebP or AVIF URL based on navigator.connection when intersecting.
  • Component libraries — React/Vue wrappers that unmount off-screen subtrees need observer lifecycle tied to component mount.

Pair lazy loading with responsive srcset, explicit width and height attributes to reserve space, and a lightweight LQIP or blur placeholder. Never lazy-load the LCP hero image; mark it fetchpriority="high" and use preload instead.

Infinite scroll and sentinel elements

Infinite scroll places an empty sentinel div at the list bottom. When the sentinel intersects, fetch the next page and append rows. Key design choices:

  • Single sentinel — one observed element at the end keeps observer count at O(1) regardless of list length.
  • Guard against duplicate fetches — set a loading flag before the network call; ignore intersection callbacks while in flight.
  • Disconnect when done — call observer.unobserve(sentinel) when the API returns no next page.
  • Accessibility — provide a “Load more” button as fallback; infinite scroll alone breaks keyboard users and screen-reader navigation unless you manage focus and announce new content.

For lists with tens of thousands of rows, combine sentinel-based fetching with virtual scrolling so DOM size stays bounded while the data array grows.

Viewability, analytics, and sticky state

Display advertising and product analytics often require “viewable impression” tracking: 50% of the ad visible for at least one continuous second. Intersection Observer with threshold: 0.5 plus a setTimeout started on intersect and cleared on exit implements the MRC standard without polling.

Other common patterns:

  • Section spy navigation — highlight the nav link whose section has the largest intersection ratio.
  • Pause off-screen video — call video.pause() when isIntersecting becomes false to save CPU and bandwidth.
  • Sticky header shadow — toggle a class when a sentinel above the header leaves the viewport.
  • Autoplay gating — start animations or carousels only when the container is visible; respect prefers-reduced-motion: reduce regardless.

Harbor Commerce catalog refactor

The Harbor Commerce team had three constraints: keep the first product row eager for LCP, prefetch the next two rows before scroll, and defer everything else. Their implementation:

  1. First six cards (two rows on desktop) render with real src and fetchpriority="high" on the lead image only.
  2. Remaining cards use data-src, a 16:9 aspect-ratio box, and a 20 px blurred LQIP inline.
  3. One shared IntersectionObserver with rootMargin: '300px 0px' and threshold: 0 observes all lazy cards.
  4. On intersect, copy data-src to src, call observer.unobserve(card), and increment a loaded counter for dev metrics.

Results on a 3G throttled profile: initial transfer dropped from 4.2 MB to 920 KB, LCP improved from 4.1 s to 2.3 s, and scroll-driven image pop-in disappeared because the 300 px margin finished decode before cards entered view. CLS held under 0.05 because aspect-ratio boxes reserved space before full images arrived.

React and framework patterns

In React, avoid creating a new observer per component render. Patterns that work:

  • Shared observer singleton — one module-level observer with a Map from element to callback; components register on mount and unregister on unmount.
  • react-intersection-observer — wraps lifecycle, supports triggerOnce for fire-and-forget lazy loads, and forwards ref to the observed node.
  • Custom hookuseInView(ref, options) returns a boolean; memoize options or use stable defaults to prevent observer recreation.

Server-rendered pages should render meaningful HTML without waiting for observer callbacks — placeholders, noscript fallbacks, or eager first-screen content. Hydration mismatches occur if client-only lazy logic hides content that SSR already painted.

Technique decision table

Need Recommended approach Why
Below-fold <img> on a static blog Native loading="lazy" Zero JS, browser-optimized, good enough for simple pages
Product grid with prefetch margin and LQIP Intersection Observer + data-src Control rootMargin, quality tiers, and unobserve after load
Fetch next API page when user nears list end Sentinel element + single observer O(1) observers; clear loading guard prevents duplicate requests
Track whether 50% of an ad was visible for 1 s Observer threshold 0.5 + timer in callback Matches MRC viewability without scroll polling
Detect element size changes (not position) ResizeObserver Intersection Observer reports visibility, not dimensions
Parallax or scroll-linked animation every frame scroll + requestAnimationFrame or CSS Observer fires on threshold crossings, not continuous scroll progress

Common pitfalls

  • Observing without unobserve — lazy-loaded images that stay observed waste callbacks on every subsequent scroll pass. Unobserve after the one-time action.
  • Wrong root in nested scrollers — modal lists and horizontal carousels need root set to the scrolling container, not the viewport.
  • Lazy-loading LCP candidates — never defer the largest above-the-fold image; it directly hurts LCP and ad revenue.
  • Zero-size targets — elements with display: none or zero height never intersect. Ensure sentinels have explicit height (even 1 px).
  • Layout shift on swap — copying data-src without reserved dimensions causes CLS. Use aspect-ratio, width/height, or skeleton boxes.
  • Ignoring reduced motion — scroll-triggered animations should check prefers-reduced-motion and skip or simplify effects.
  • Creating observers in render loops — leaks memory and duplicates callbacks. One observer per purpose, shared across targets.

Production checklist

  • Identify LCP element; keep it eager with preload or high fetchpriority.
  • Choose native lazy vs custom observer; document rootMargin prefetch distance.
  • Reserve space for lazy media (aspect-ratio, width/height, skeleton).
  • Share one observer instance across many targets where possible.
  • Call unobserve after one-time actions (image load, analytics fire).
  • Guard infinite-scroll callbacks with an in-flight loading flag.
  • Set root correctly for nested scroll containers and modals.
  • Provide “Load more” fallback for infinite scroll accessibility.
  • Respect prefers-reduced-motion for scroll-triggered animations.
  • Measure LCP, CLS, and INP before and after on throttled mobile profiles.

Key takeaways

  • Intersection Observer delivers async callbacks when elements cross a visibility threshold — no per-frame scroll polling.
  • Configure root, rootMargin, and threshold to match prefetch distance, nested scrollers, and viewability rules.
  • Native loading="lazy" is enough for simple pages; custom observers add prefetch control, CSS backgrounds, and quality tiers.
  • Never lazy-load LCP heroes; pair below-fold lazy loading with reserved dimensions to prevent CLS.
  • Share observers, unobserve after one-time work, and combine sentinels with virtual scrolling for large lists.

Related reading