Replacing scroll·resize Events with IntersectionObserver·ResizeObserver: How to Eliminate Forced Reflow
There comes a moment in frontend development when you open the Performance tab and just stare blankly at it — the sight of yellow bars packed in every time you scroll. I too initially thought "just add a debounce," only to realize later that the root cause wasn't the event handler itself, but the way it was reading the DOM. The moment you call getBoundingClientRect() inside a scroll handler, the browser is forced to recalculate the layout to return an up-to-date coordinate value. Repeated at 60fps scroll speed, the main thread simply can't keep up.
Switching to IntersectionObserver and ResizeObserver changed more than just code cleanliness. "Recalculate Style" and "Layout" entries in Chrome DevTools decreased noticeably, and frame drops during scrolling disappeared. Replacing the pattern of reading the DOM inside scroll·resize event handlers with the Observer API is a far more fundamental performance improvement than stopgaps like debouncing — and the code actually becomes simpler.
In this article, I'll start with the principles behind why these two APIs are fast, walk through how to apply them in real-world scenarios like image lazy loading, infinite scroll, and responsive components, and honestly share the pitfalls you'll commonly encounter in practice. If you have more than a year of frontend development experience, everything here is something you can apply right now.
Core Concepts
The Real Reason the Old Approach Is Slow
Let's start with the problematic code.
window.addEventListener('scroll', () => {
const rect = el.getBoundingClientRect(); // triggers forced layout recalculation
if (rect.top < window.innerHeight) showElement();
});
window.addEventListener('resize', () => {
recalcLayout(); // called hundreds of times per second while dragging
});getBoundingClientRect() is a function that demands of the browser: "give me the exact coordinates right now." From the browser's perspective, it must apply all accumulated style changes up to that point and compute the layout before it can respond. This is called Forced Reflow, and when it repeats inside a scroll event, the entire layout→paint→compositing pipeline restarts every frame.
Forced Reflow: The phenomenon where the browser is forced to recalculate layout when layout-related properties (
offsetWidth,getBoundingClientRect(), etc.) are read from JS. It leads to Layout Thrashing especially when style writes and size reads alternate repeatedly, easily blowing past the 16ms frame budget.
Why the Observer API Is Different
IntersectionObserver and ResizeObserver have the browser internally detect intersection and size changes, then deliver callbacks to the main thread in an asynchronous, batched manner. It's not that "the measurement itself is offloaded to another thread" — rather, the browser determines the optimal timing on its own and processes callbacks in bulk. This means the main thread no longer needs to wake up on every scroll event.
| API | What it detects | Primary use cases |
|---|---|---|
IntersectionObserver |
Whether an element intersects with the viewport (or a parent container) | Image lazy loading, infinite scroll, entry animations |
ResizeObserver |
Changes to an element's content/border box size | Responsive components, canvas resolution adjustment, dynamic layouts |
ResizeObserver runs after layout calculation but before paint. This timing matters because it reads sizes immediately after layout has already completed, meaning it does not trigger Forced Reflow. Since the size value is delivered directly as a callback argument (contentRect), there's no need to query it separately.
Compositor thread: A separate thread in the browser that composites layers and outputs them to the screen. This is why scrolling and CSS
transformanimations remain smooth even when the main thread is busy.
Browser Support (as of 2026)
"Does this work in older browsers?" is always the first question that comes up. The short answer: you don't need a polyfill.
| API | Chrome | Firefox | Safari |
|---|---|---|---|
IntersectionObserver |
51+ | 55+ | 12.1+ |
ResizeObserver |
64+ | 69+ | 13.1+ |
IE is out of the picture, and as of 2026, if you need to support versions below those, you have bigger problems to deal with.
Practical Application
Now that we understand the principles, let's look at actual code. I've prepared five scenarios that progressively increase in difficulty and scope. Examples 1–3 are written in plain JavaScript; examples 4–5 use TypeScript and framework patterns.
Example 1: Image Lazy Loading
This pattern stores the actual URL in data-src instead of <img src="...">, then swaps in src the moment the element enters the viewport. It's the most classic use case but also one of the most impactful optimizations — the effect is immediately visible in the Network panel, which also helps convince teammates.
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
}, { rootMargin: '200px' });
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));| Option/Method | Purpose |
|---|---|
rootMargin: '200px' |
Detects 200px ahead of the viewport → image finishes loading before the user sees it |
observer.unobserve(img) |
Stops observing immediately after load → no wasted memory |
entry.isIntersecting |
Boolean indicating whether the element is intersecting |
Example 2: Infinite Scroll
Place an invisible sentinel element at the bottom of the list, and request the next page when that element enters the viewport. The implementation is simpler than pagination while the UX feels far more natural.
This is a situation you'll frequently encounter in practice: fetchNextPage() can be called again while still in progress if the sentinel intersects a second time. Duplicate requests cause garbled data or API over-calling. Managing an isLoading flag alongside it is the most reliable way to avoid this trap.
const sentinel = document.querySelector('#sentinel');
let isLoading = false;
const observer = new IntersectionObserver(async ([entry]) => {
if (entry.isIntersecting && !isLoading) {
isLoading = true;
await fetchNextPage();
isLoading = false;
}
}, { threshold: 0.1 });
observer.observe(sentinel);
threshold: 0.1triggers the callback when the sentinel is more than 10% visible in the viewport. Values closer to 0 detect the element even when it barely overlaps.
Example 3: Responsive Components with ResizeObserver
Media queries are viewport-based and can't know the container size. If you want to change a card layout when a sidebar collapses, ResizeObserver used to be the only JS solution — though now CSS Container Queries cover many pure layout changes. Use ResizeObserver when you need runtime state changes or when CSS alone won't cut it, such as with canvas.
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
const { width } = entry.contentRect;
entry.target.classList.toggle('compact', width < 480);
}
});
ro.observe(document.querySelector('.card'));contentRect already contains the width and height, so there's no need to call getBoundingClientRect(). And if the size hasn't changed, the callback simply won't fire — no unnecessary computation.
Example 4: React Custom Hook
In React, the pattern of creating an Observer in useEffect and calling disconnect() in the cleanup has become the de facto standard idiom. One important caveat: don't put [ref] in the dependency array. The ref object itself maintains the same reference across renders, so if ref.current changes, the Effect won't re-run. This can cause the Observer to not attach in SSR environments or conditional rendering scenarios. It's safer to use [] and capture the node in a local variable inside the Effect — that way cleanup uses the same reference.
import { useState, useEffect, RefObject } from 'react';
function useElementSize(ref: RefObject<Element>) {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const node = ref.current; // guarantees the same reference in cleanup
if (!node) return;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setSize({ width, height });
});
ro.observe(node);
return () => ro.disconnect();
}, []); // ref object is the same reference across renders, so []
return size;
}If rolling your own feels tedious, using a library is also a perfectly good choice.
| Package | Notes |
|---|---|
react-intersection-observer |
useInView hook + <InView> component, TypeScript support |
use-resize-observer |
648B (gzip), saves memory by sharing a single Observer instance |
@react-hook/resize-observer |
Shares one ResizeObserver instance across the entire app |
Example 5: Scroll Entry Animations
This pattern applies effects like fade-in or slide-up when elements enter the screen. Implementing it with scroll events required calculating coordinates for every element on every frame, but IntersectionObserver only fires a callback when an actual intersection occurs.
const observer = new IntersectionObserver((entries) => {
entries.forEach(({ target, isIntersecting }) => {
target.classList.toggle('visible', isIntersecting);
});
}, { threshold: 0.2 });
document.querySelectorAll<Element>('.animate-on-scroll').forEach(el => observer.observe(el));If you want the animation to play only once, call observer.unobserve(target) when isIntersecting is true.
Pros and Cons
Drawbacks and Caveats
To be honest, the advantages far outweigh the drawbacks, but there are real pitfalls I've personally fallen into, so I've compiled them here.
| Issue | Description | Mitigation |
|---|---|---|
| ResizeObserver loop error | Changing the size of an observed element inside its callback causes a feedback loop | Defer to the next frame with requestAnimationFrame |
| IntersectionObserver precision limits | Not suitable for pixel-accurate position tracking | For drag-and-drop, sticky offset calculations, use scroll + getBoundingClientRect() |
| Overlap with CSS Container Queries | If you're only changing styles, introducing JS at all is overkill | Replace with @container queries → no CLS (layout shift) risk either |
CSS Container Queries: A CSS feature that branches child element styles based on the parent container's size using
@containerrules. Supported in all major browsers since February 2023, and Container Scroll Queries were added to Chrome and Edge in December 2025. For pure layout changes, this is more maintainable thanResizeObserver.
The Most Common Mistakes in Practice
-
Unmounting a component without calling
disconnect()— if the Observer is still alive, it keeps referencing a DOM element that's already gone, causing a memory leak. I've actually received memory leak alerts from forgettingdisconnect(). In React, make sure to put it inuseEffectcleanup; in Vue, put it inonUnmounted. -
Directly modifying the size of an observed element inside its callback — this is the primary culprit behind the
ResizeObserver loop completed with undelivered notificationswarning. If you need to change the size, deferring it to the next frame withrequestAnimationFramebreaks the loop.javascript// ❌ causes feedback loop ro.observe(el); const ro = new ResizeObserver(([entry]) => { el.style.height = entry.contentRect.width + 'px'; }); // ✅ defer to next frame const ro = new ResizeObserver(([entry]) => { requestAnimationFrame(() => { el.style.height = entry.contentRect.width + 'px'; }); }); -
Confusing
thresholdandrootMargin—thresholdtriggers the callback based on the intersection ratio (0–1), whilerootMarginexpands or shrinks the detection area itself. A good rule of thumb: userootMarginfor image preloading, and use something likethreshold: 0.5when you want to trigger an animation once an element is more than half visible.
Closing Thoughts
After reading this article, you'll have the perspective to distinguish between "is this a memory leak or a scroll event flood?" Switching to the Observer API means you no longer need to maintain debounce logic, you don't have to think separately about when to query the DOM, and you only need to worry about cleanup when a component unmounts. Those are three decisions you'll now make differently than before.
Here's how you can start right away:
- It's recommended to measure your current state first — record a scroll session in Chrome DevTools and check how often "Recalculate Style" and "Layout" entries appear. This becomes your baseline for before-and-after comparison.
- You can start by applying image lazy loading — the
img[data-src]pattern can be introduced with minimal changes to existing code, and the effect is immediately visible in the Network panel, which also helps convince your team. If you're using React, starting with thereact-intersection-observerlibrary will make it even easier. - Before introducing ResizeObserver, it's worth checking whether CSS Container Queries can solve the problem first — if style branching is possible with
@containerqueries, the render engine handles it directly without any JS, there's no CLS risk, and the code is much cleaner. The recommended approach is to reserveResizeObserverfor cases where JS is truly necessary (canvas resolution, runtime state transitions, etc.).
References
- Intersection Observer API | MDN Web Docs
- Resize Observer API | MDN Web Docs
- Use Intersection Observer instead of Scroll Events | Jonathan Lau Blog
- Alternatives to the resize event with better performance | Tiger Oakes
- Performant Alternative to addEventListener for Scroll and Resize Events | GIT ER OPTIMIZED
- Using the ResizeObserver API in React for responsive designs | LogRocket
- Lazy loading using the Intersection Observer API | LogRocket
- Resolving "ResizeObserver Loop Limit Exceeded" Errors | DhiWise
- Avoiding pitfalls with the resize event in JavaScript | OpenReplay
- use-resize-observer | GitHub (ZeeCoder)
- react-intersection-observer | Vercel