Intersection Observer API Explained
The Intersection Observer API tells you when an element enters or leaves the viewport, without scroll-event polling. How it works and where to use it.
The Intersection Observer API is a browser API that lets you asynchronously watch when an element crosses into or out of another element’s viewport — most commonly the browser window — without touching scroll events at all. Instead of computing element positions on every scroll tick, you register a callback and the browser tells you when the intersection changes.
The problem it replaces
Before Intersection Observer, detecting whether an element was visible meant listening to scroll (and often resize) and calling getBoundingClientRect() on every event to check element positions manually. Scroll events fire dozens of times per second, and getBoundingClientRect() forces a synchronous layout recalculation — a classic cause of jank on scroll. Developers worked around this with setTimeout/debounce throttling, which helped but never eliminated the layout thrashing.
Intersection Observer moves the work off the main thread’s hot path. The browser tracks intersections internally and only invokes your callback when something actually changes, batched and asynchronous. It’s one of the cleaner examples of a browser API designed specifically to fix a well-known performance anti-pattern.
Basic usage
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
}
}
}, {
root: null, // viewport
rootMargin: "0px",
threshold: 0.1, // fire at 10% visible
});
document.querySelectorAll(".card").forEach((el) => observer.observe(el));
Each entry in the callback carries isIntersecting, intersectionRatio, boundingClientRect, and a reference to target, the observed element. You can observe many elements with a single observer instance — the browser reports on whichever ones actually crossed a threshold.
The three options that matter
root— the element used as the viewport for checking visibility.null(the default) means the browser viewport. Set this to a scrollable container’s element to observe intersections within that container instead.rootMargin— grows or shrinks the root’s bounding box before intersection is computed, using CSS-margin-like syntax ("200px 0px"). This is how you trigger a callback before an element is actually visible — load an image 200px before it scrolls into view.threshold— a number or array of numbers between 0 and 1 specifying what percentage of the target must be visible before the callback fires.[0, 0.25, 0.5, 0.75, 1]fires at each quarter-visibility milestone, useful for scroll-progress indicators.
Common use cases
Lazy loading. The most common application: don’t fetch an image or run expensive work until the element is about to enter the viewport. Modern browsers now offer native loading="lazy" for images, covered in our guide to lazy loading images, but Intersection Observer is still the right tool for lazy-loading non-image content — video players, iframes, chart libraries, or third-party embeds where there’s no native attribute.
Infinite scroll. Observe a sentinel element at the bottom of a list; when it intersects, fetch the next page. This avoids scroll-position math entirely — you just watch one invisible marker element.
Scroll-triggered animations. Add a class when an element enters the viewport, then let CSS transitions or the View Transitions API handle the actual animation. For animations driven directly by scroll position rather than enter/exit events, CSS scroll-driven animations are now a native alternative that requires no JavaScript at all.
Ad viewability tracking. Advertisers need to know not just that an ad was in the DOM, but that a meaningful percentage of it was actually on screen for a meaningful duration — exactly what intersectionRatio and repeated threshold callbacks report.
Sticky header state. Observe a marker at the top of the page; when it stops intersecting, you know the user has scrolled past the fold and can toggle a “shrink header” or “show back-to-top button” class.
How it interacts with Core Web Vitals
Used well, Intersection Observer directly improves the metrics covered in our Core Web Vitals guide. Deferring offscreen work reduces main-thread contention during initial load, which helps Interaction to Next Paint. But it can also hurt Largest Contentful Paint if misused — don’t lazy-load the hero image or any above-the-fold content that contributes to LCP, since deferring it just delays the paint you’re trying to speed up.
It pairs naturally with requestIdleCallback and requestAnimationFrame: use Intersection Observer to decide whether work is needed, and one of those two to decide when to actually run it relative to the browser’s paint cycle.
Intersection Observer vs Resize Observer vs Mutation Observer
These three sit in the same family of “observe DOM changes without polling” APIs, but they answer different questions:
| Watches for | Typical use | |
|---|---|---|
| Intersection Observer | Visibility relative to a root | Lazy loading, infinite scroll, viewability |
| Resize Observer | An element’s size changing | Responsive components, container queries polyfills |
| Mutation Observer | DOM tree changes (nodes, attributes) | Reacting to third-party script DOM edits |
They’re complementary, not competing — a component library might use Resize Observer to react to its own size and Intersection Observer to decide whether it’s worth rendering at all.
A note on cleanup
Always call observer.unobserve(element) or observer.disconnect() when an element is removed from the DOM or a component unmounts. An observer holding a reference to a detached element is a common, quiet memory leak in single-page apps — the observer keeps the element (and anything it closes over) alive even though nothing else references it.
The takeaway
Intersection Observer replaces manual scroll-event math with an async, browser-native way to know when elements enter or leave a viewport. Use rootMargin to prefetch before visibility, threshold arrays for granular visibility tracking, and always disconnect observers on cleanup. It’s the right default for lazy loading, infinite scroll, and scroll-triggered UI — just keep it away from anything that contributes to your Largest Contentful Paint.
Keep reading
Takina · · 4 min read requestIdleCallback Explained
requestIdleCallback runs low-priority JavaScript when the browser is idle, without blocking rendering, input, or the main thread.
Takina · · 4 min read Dynamic import() in JavaScript: Code-Splitting Explained
JavaScript's dynamic import() loads a module on demand and returns a promise, letting you split bundles and defer code until it's actually needed.
Takina · · 5 min read Finding and Fixing Memory Leaks in JavaScript
A JavaScript memory leak happens when a reference outlives its usefulness and the garbage collector can't reclaim it. Common causes and how to find them.