IntersectionObserver finally saved my infinite scroll from choking
My old scroll handler ate 15ms just checking if elements were visible. Switching to IntersectionObserver dropped that to under 1ms. Here is how I wired it up with a debounced loader and why you should stop using scroll events for visibility.
The problem with scroll events
I spent two years building an e-commerce site that loaded products via infinite scroll. Every time the user scrolled, I fired an event listener. Inside that listener, I checked if an element was in the viewport to decide whether to load more items.
It worked fine for a small shop. But when we added 500 products to the catalog, the browser started lagging. My Chrome DevTools Performance tab showed the scroll event taking 15ms just to check a boolean flag. That sounds small, but it happened every time I moved my mouse.
Then I realized I was doing the same thing with a for loop that checked every single product card to see if it was visible. That was unnecessary. I was checking elements I did not care about. I also had to worry about the browser freezing the main thread because I was doing too much work inside a single event loop tick.
I tried throttling the scroll event. I tried debouncing. But none of them felt right. They all added complexity and still felt like a hack. I needed something that the browser knew how to handle natively.
Why IntersectionObserver is different
IntersectionObserver is a browser API that tells you when an element enters or leaves the viewport. It runs in its own background thread. That means it does not block the main thread. My scroll handler stayed fast because the browser handled the visibility checks for me.
I can set it up to only call my function when an element actually changes state. It does not fire on every single pixel of movement. It fires when an element crosses the threshold I set. This reduced my scroll handler time from 15ms to under 1ms. That is a 15x improvement.
Here is the code I used to replace my old scroll listener. It is simple and readable.
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadMoreProducts();
}
});
}, {
rootMargin: '100px',
threshold: 0.1
});
observer.observe(document.querySelector('.product-card'));
Notice the rootMargin option. I set it to 100px. This means the observer starts watching when the element is 100px away from the bottom of the viewport. This prevents a flash of empty content. The browser knows exactly when to trigger the callback. It does not need me to manually check if the element is visible.
Another benefit is that I can stop observing elements once they have loaded. This keeps the memory footprint low. If I did not do this, the browser would keep tracking elements I did not need anymore. That would eventually slow down the app again.
Handling multiple elements
I needed to handle multiple product cards. I did not want to write a loop that checked every single one. I used a single observer instance and observed multiple elements. This is a common pattern and it works well. The browser batches the notifications so I only get one call even if multiple elements cross the threshold.
I also had to handle the case where the user scrolls back up. If they scroll back up, the observer should stop loading more products. I added a simple check to ensure I only load products when the user is near the bottom.
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 150) {
loadMoreProducts();
}
} else {
// Stop loading if user scrolls back up
observer.unobserve(entry.target);
}
});
}, {
rootMargin: '100px',
threshold: 0.1
});
// Observe all product cards
document.querySelectorAll('.product-card').forEach(card => {
observer.observe(card);
});
Notice the unobserve call. I remove the observer when the user scrolls back up. This keeps the memory usage low. I also check the scroll position to ensure I only load products when the user is near the bottom. This prevents loading products when the user is at the top of the page.
Debugging the observer
I ran into a bug where the observer did not fire. I checked the console and found that I had not set the root option correctly. By default, the observer watches the viewport. But if I had a custom scroll container, I needed to set the root option to that container.
I also had to handle the case where the observer did not fire for elements that were already visible. I set the threshold to 0.1. This means the observer fires when 10% of the element is visible. This ensures that the observer fires even if the element is partially visible.
I also added a debug flag to my code. I logged the observer entries to the console. This helped me see when the observer fired and which elements were visible. This made debugging much easier.
if (DEBUG) {
observer.observe(entry.target);
console.log('Observer fired:', entry.isIntersecting, entry.target);
}
I did not use this flag in production. But it was helpful during development. It helped me catch bugs early. It also helped me understand how the browser handles visibility checks.