TL;DR / IntersectionObserver is an async API that efficiently detects when elements enter or exit the viewport (or any ancestor container), replacing expensive scroll-event-based visibility checks.
How It Works
╔════════════════════════╗ ┌──────────────────────┐
║ ║ │ IntersectionObserver │ threshold: [0, 0.5, 1]
║ ┌──────────────────┐ ║ └──────────────────────┘ rootMargin: "0px"
║ │ Visible Element │ ║ │
║ └──────────────────┘ ║ │
║ ║ │
║ ║ │
║ ║ │
║ Root (viewport) ║───┐ │
║ ║ │ ↓
║ ║ │ ┌──────────────────────┐
║ ┌──────────────────┐ ║ └───→│ Callback fires │
║ │ Entering │ ║ └──────────────────────┘
║ │ Element │ ║
║ └──────────────────┘ ║
╚════════════════════════╝
Before IntersectionObserver existed, detecting whether an element was visible required attaching a handler to the scroll event and calling getBoundingClientRect() on every target element per frame. This forced layout recalculations on the main thread, creating jank precisely when the user was scrolling. IntersectionObserver moved this computation off the main thread entirely.
The Observer Pattern
You create an observer by passing a callback and an options object. The options define three things: the root (the scrollable ancestor, or null for the viewport), the rootMargin (a CSS-margin-like string that expands or contracts the root's bounding box), and threshold (an array of intersection ratios from 0.0 to 1.0 at which the callback fires).
When you call observer.observe(element), the browser registers that element for intersection tracking. The browser implementation uses an internal scheduling mechanism tied to the rendering pipeline -- specifically, intersection observations are computed after layout but before paint, during a step the spec calls "run the update intersection observations steps." This happens once per frame, not on every scroll pixel.
How Thresholds Work
Thresholds define the exact visibility ratios that trigger the callback. A threshold of [0] fires when the element transitions from zero visibility to any visibility (or vice versa). A threshold of [0, 0.5, 1.0] fires at 0%, 50%, and 100% visibility transitions. The browser only fires the callback when the element crosses a threshold boundary, not continuously. This is a crucial distinction -- you get discrete events, not a stream.
Each callback invocation receives an array of IntersectionObserverEntry objects. Each entry contains intersectionRatio (the fraction visible), isIntersecting (boolean), intersectionRect (the visible portion's geometry), boundingClientRect, rootBounds, target, and time (a DOMHighResTimeStamp). The entry is a snapshot; these values are not live.
Root Margin and Virtual Boundaries
The rootMargin option is deceptively powerful. Setting rootMargin: "200px" effectively expands the detection zone 200px beyond the viewport in every direction. This enables preloading images or components before they scroll into view. Negative margins shrink the detection zone, useful for triggering animations only when an element is fully within a centered region. The margin values follow CSS shorthand: top, right, bottom, left.
Performance Characteristics
The browser coalesces intersection checks to once per frame at most. Multiple observed elements are batch-processed in a single pass. The callback itself runs asynchronously as a regular task (not a microtask), meaning it will not block rendering. However, whatever work you do inside the callback runs on the main thread. If your callback triggers DOM mutations, style recalculations, or heavy computation, you reintroduce the performance problem you were trying to avoid.
A single observer can watch multiple elements, and this is strongly preferred over creating one observer per element. Each observer instance carries overhead for the internal intersection computation data structures. Watching 1000 elements with one observer is significantly cheaper than 1000 observers watching one element each.
Browser Implementation Details
The spec mandates that intersection calculations use the "content clip" of scrollable ancestors, meaning elements clipped by overflow: hidden on a parent are correctly reported as non-intersecting even if geometrically within the root's bounds. CSS transforms are accounted for in the bounding rect calculations, but the intersection computation itself operates on axis-aligned bounding boxes. A rotated element's intersection ratio is based on its axis-aligned bounding rect, not its visual shape.
IntersectionObserver v2 adds the trackVisibility option and the isVisible property on entries. This detects whether an element is actually visible to the user -- not obscured by other content, not opacity: 0, not visibility: hidden. This is computationally expensive and requires a minimum delay of 100ms between notifications.
Gotchas
- Callbacks are async and batched -- if you need to react to visibility changes synchronously before paint, IntersectionObserver cannot guarantee it. The callback fires as a regular task, potentially one or more frames after the actual intersection change.
isIntersectingis not the same asintersectionRatio > 0-- due to edge cases with zero-area elements, an element can be intersecting with a ratio of 0. Always useisIntersectingfor visibility checks.- CSS transforms on ancestors affect bounding rects but the intersection is computed with axis-aligned bounding boxes, so rotated or skewed elements may report unintuitive ratios.
- Creating one observer per element is an anti-pattern -- the API is designed for a single observer to track many elements. Each additional observer adds per-frame computation overhead.
rootMarginonly works whenrootis an element or null -- when usingroot: null(viewport), the margin extends the viewport. Whenrootis an element, it extends that element's bounding box. ButrootMarginis ignored if the root element is not an ancestor of the observed element.