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;
rootMarginlets you start fetches earlier on slow connections. - Background images and CSS — native lazy applies to
<img>and<iframe>, notbackground-imageon divs. - Conditional quality tiers — swap
data-srcto a WebP or AVIF URL based onnavigator.connectionwhen 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()whenisIntersectingbecomes 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: reduceregardless.
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:
- First six cards (two rows on desktop) render with real
srcandfetchpriority="high"on the lead image only. - Remaining cards use
data-src, a 16:9 aspect-ratio box, and a 20 px blurred LQIP inline. - One shared
IntersectionObserverwithrootMargin: '300px 0px'andthreshold: 0observes all lazy cards. - On intersect, copy
data-srctosrc, callobserver.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, supportstriggerOncefor fire-and-forget lazy loads, and forwards ref to the observed node.- Custom hook —
useInView(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
rootset 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: noneor zero height never intersect. Ensure sentinels have explicit height (even 1 px). - Layout shift on swap — copying
data-srcwithout reserved dimensions causes CLS. Use aspect-ratio, width/height, or skeleton boxes. - Ignoring reduced motion — scroll-triggered animations
should check
prefers-reduced-motionand 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
unobserveafter one-time actions (image load, analytics fire). - Guard infinite-scroll callbacks with an in-flight loading flag.
- Set
rootcorrectly for nested scroll containers and modals. - Provide “Load more” fallback for infinite scroll accessibility.
- Respect
prefers-reduced-motionfor 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
- Web image optimization explained — WebP, AVIF, srcset, and lazy loading patterns
- Core Web Vitals explained — LCP, INP, CLS and how lazy loading affects them
- Virtual scrolling explained — windowing large lists and pairing with infinite fetch
- Resource hints explained — preload and fetchpriority for above-the-fold assets