JavaScript Observer APIs Table
| Observer | Watches | Field note |
|---|---|---|
IntersectionObserver | Enters/leaves the viewport | Lazy-load and infinite scroll - replaces scroll-math forever |
{ threshold: 0.5 } | Fire at visibility fraction | Array of thresholds - 0 = any pixel, 1 = fully visible |
{ rootMargin } | Grow the watched box | 200px preload margin - images ready BEFORE they scroll in |
MutationObserver | DOM changes | Attribute/childList/characterData - react to markup you do not own |
ResizeObserver | Element box changes | Per-ELEMENT (not window) - container-driven widgets without media queries |
PerformanceObserver | Metrics stream | LCP, longtasks - the Core Web Vitals collection pipe |
entry.isIntersecting | The lazy-load gate | Unobserve after first fire - one-shot loading |
disconnect() | Stop watching | Same teardown discipline as event listeners |
The observer family replaced scroll-event math with subscriptions: IntersectionObserver answers 'is it visible yet' without reading layout, MutationObserver reacts to DOM changes, ResizeObserver watches element boxes (not the window), and PerformanceObserver streams metrics. Ask once, get called back.
Bottom line: rootMargin extends the intersection box - a 200px bottom margin fires lazy loads BEFORE images scroll into view, which is what kills the pop-in effect native-feeling sites need. One-shot loads unobserve after the first intersection; every teardown path calls disconnect.
The honest part: observer callbacks are asynchronous and batched - they run after layout, at a time the browser chooses, so you cannot read them synchronously inside the same tick. That asynchrony is the feature (no layout thrash), but it means an intersection handler must not assume the exact frame.
How to use
- Lazy-load properly: new IntersectionObserver(entries => { if (entries[0].isIntersecting) { load(); obs.unobserve(el); } }, { rootMargin: '200px' }) - preload before arrival, fire once.
- Infinite scroll without scroll events: observe a SENTINEL div after the last item - when it intersects, fetch the next page and re-append.
- Watch your container: new ResizeObserver(entries => relayout(entries[0].contentRect)) on a widget root - responsive to the container, no media query coupling.
Frequently asked questions
How is IntersectionObserver different from a scroll listener plus getBoundingClientRect?
Inversion of cost. The scroll approach runs your math on every scroll event, reads layout synchronously (getBoundingClientRect can force reflow), and requires you to debounce or rAF-throttle to avoid jank. IntersectionObserver inverts it: you declare WHICH element and WHAT visibility threshold, the browser calls you back asynchronously after layout when the condition changes - no per-event work, no layout reads, no thrash. The old pattern is also wrong at the edges: scroll listeners miss programmatic scrolls (anchor jumps) in some setups, while intersection is driven by actual visibility, whatever caused it.
What do threshold and rootMargin actually control?
Threshold sets the visibility FRACTION that triggers: 0 fires when any pixel appears (default), 0.5 at half-visible, 1 when fully visible - and it accepts an array, calling back at each stage (a view-tracking widget might want 0.25, 0.5, 0.75). rootMargin grows or shrinks the ROOT box before intersecting: '200px 0px' effectively treats the viewport as extending 200px further down, so lazy images start loading before they enter - the single most important knob for killing pop-in. Negative margins shrink it, delaying triggers for expensive content.
When do I need MutationObserver instead of just hooks in my own code?
When the DOM changes come from code you do not own: third-party scripts, browser extensions, content editable, or framework internals. MutationObserver subscribes to childList (added/removed nodes), attributes and characterData changes - the building block for ad blockers, translation overlays, rich-text editors reacting to user edits, and test harnesses. For your own components, direct function calls are faster and clearer; the observer earns its keep precisely at the boundary where someone else's code mutates the page and you need to react without patching their source.
Why does ResizeObserver matter when window.resize exists?
Different questions. window.resize answers 'how big is the SCREEN' - useless for a component inside a flex sibling, a resizable panel, or a container-query-style layout, where the viewport never changed but the element's box did. ResizeObserver watches a specific ELEMENT's content box and fires when IT changes for any reason (window resize, sibling content growth, font load, container drag). That is the primitive container-adaptive widgets were built on before CSS container queries: a chart that redraws to its card's width, independent of how the card got its size. The loop caution: resizing INSIDE your own ResizeObserver callback can re-trigger it - guard or debounce your relayouts.