What the observer reports
IntersectionObserver watches one or more target elements and
asynchronously notifies a callback when their intersection with a
root element (or the viewport) crosses a configured threshold.
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add('in-view');
io.unobserve(entry.target);
}
}
}, {
root: null, // viewport
rootMargin: '0px 0px -100px 0px',
threshold: 0.1, // 10% visible fires
});
document.querySelectorAll('.lazy').forEach((el) => io.observe(el));
Each entry’s intersectionRatio is a number from 0 to 1 indicating
the fraction of the target’s bounding rect inside the root.
isIntersecting is convenient shorthand for ratio > 0.
Why it replaces scroll listeners
Pre-2017, lazy-load and reveal-on-scroll were built on scroll
event listeners that fired up to 60 times per second. The
listener would getBoundingClientRect() every observed element,
forcing layout. Real-world overhead on a moderate page reached
20–40 ms of main-thread jank per scroll second.
IntersectionObserver runs on the engine’s compositor thread and
delivers entries asynchronously. Callbacks fire only at
transitions, batched in a microtask. Typical overhead is below
1 ms per second of scrolling on the Chromium 2023 perf benchmark.
Constructor options
| Option | Type | Default | Purpose |
|---|---|---|---|
root | Element or Document or null | viewport | Reference rect for intersection. |
rootMargin | CSS-margin string | 0px | Grow or shrink the root rect for off-screen pre-loading. |
threshold | number or array | 0 | Single ratio or list of ratios at which to fire. |
rootMargin accepts each side individually; the negative-margin
trick lets you fire when an element is “still 100 px below the
fold” — useful for reveal animations.
threshold: [0, 0.25, 0.5, 0.75, 1] fires at every quartile,
useful for progress bars or animation timelines.
Common patterns
Lazy-load images
const io = new IntersectionObserver((entries, obs) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
const img = e.target;
img.src = img.dataset.src;
obs.unobserve(img);
}
});
document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));
A pure-CSS alternative — <img loading="lazy"> — exists since
2020 in all three engines. Use the native attribute first; reach
for the observer only when CSS loading does not give enough
control over the activation point.
Infinite scroll
const sentinel = document.querySelector('#more-sentinel');
new IntersectionObserver(async ([e]) => {
if (!e.isIntersecting) return;
const more = await fetch(`/items?cursor=${cursor}`).then((r) => r.json());
appendItems(more);
cursor = more.cursor;
}, { rootMargin: '500px' }).observe(sentinel);
A 500-pixel root margin pre-fetches the next page about 1 second before the user reaches it on a typical scroll velocity.
Sticky state detection
const io = new IntersectionObserver(
([entry]) => header.toggleAttribute('data-stuck', !entry.isIntersecting),
{ threshold: [1] },
);
io.observe(headerSentinel);
A 1-pixel sentinel above the sticky header yields the moment the header becomes stuck. About 80% of “is this header stuck?” use cases simplify to this pattern.
IntersectionObserver v2
IntersectionObserver v2 adds tracking of visual occlusion: whether the target is actually painted, or whether another element covers it (overlay, modal). Available only in Chromium 74+ (2019); not implemented in WebKit or Gecko as of 2025.
Common pitfalls
getBoundingClientRect()inside the callback. The engine has just given you the rect viaentry.boundingClientRect; re-querying triggers layout. Use the entry data.- Long-running synchronous work in the callback. Defer to
requestIdleCallbackor break work into chunks. The callback runs on the main thread. disconnect()not called. A long-lived observer with forgotten observers prevents element garbage collection.- Threshold of
0then expecting “halfway visible”.0fires at the first pixel of intersection; you want0.5. - Root must be an ancestor. A non-ancestor
rootcauses the observer to never fire.
Cross-engine support
IntersectionObserver v1 reached interop in 2017 across Chromium,
Firefox, and Safari (12.0). Pass-rate on the WPT subset
is about 99% across the three engines as of the 2024 dashboard.
caniuse for intersection-observer reports about 99% global support. The v1 polyfill from W3C (maintained until 2021) is still occasionally used for legacy browser fleets.
Further reading
- W3C Intersection Observer Living Standard.
- The v2 spec for visual-occlusion detection.
- Surma’s
isIntersectinghistory explains why the API runs on the compositor. - Web.dev’s lazy-load patterns
benchmark observer-based lazy loading against the native
loadingattribute.