Infinite scrolling with intersection observer
Javascript | Nexsaar
Ever noticed how apps like Facebook or Pinterest just keep loading new posts as you scroll - without you clicking anything? One moment you’re at the bottom of the page, and the next, fresh content magically appears.
So how does that actually work?
Behind the scenes, the app keeps an eye on where you are in the viewport. As soon as you scroll close to a certain point (usually near the bottom), it triggers an API call to fetch more data. The result? New posts slide right into your feed, giving that smooth never-ending scroll experience we’re all familiar with.
That’s what we call infinite scrolling.
How does infinite scrolling work?
There are multiple ways to build infinite scrolling, but in this we will walk through two very common ways :
1. The traditional approach: Scroll events + getBoundingClientRect()
In this method, you manually listen to the user's scroll activity and continuously check whether an element has entered the viewport.
A common example is lazy-loading images - loading an image only when it becomes visible on screen.
Here's how that usually looks:
// TRADITIONAL APPROACH - Scroll Event + getBoundingClientRect()
function isElementInViewport(element) {
const rect = element.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
)
}
let lazyImages = document.querySelectorAll('.lazy-image')
function checkLazyImages() {
lazyImages.forEach((img, index) => {
if (isElementInViewport(img)) {
// Load the image
img.src = img.dataset.src
img.classList.add('loaded')
// Remove it so we don’t check again
lazyImages = Array.from(lazyImages).filter((_, i) => i !== index)
}
})
}
Now the problem with this approach is that:
- Scroll events fire frequently and force synchronous layout calculations, leading to laggy scrolling performance.
- Runs synchronously on main thread, blocking other operations.
getBoundingClientRect()forces immediate layout calculations, even if the browser was trying to batch them for efficiency. With frequent scroll events, this could happen hundreds of times per second.- Due to these frequent computations, this could lead to battery drain problem. Even with optimisation approach like throttling, still has performance issues
window.addEventListener('scroll', throttle(checkLazyImages, 100));
Well then, what’s the better way? Here comes the Intersection Observer API to the rescue!
2. Intersection observer
The intersection observer api was designed specifically, to solve the performance issues and improve developer experience.
Instead of constantly polling element positions, it lets you register a callback that runs asynchronously when intersection changes occur.
The IntersectionObserver API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with the top-level document's viewport.
"Think of it like a watchman that tells you when an element enters or exits the visible area of the page."
Where can you use intersection observer?
- Lazy loading images
- Infinite scrolling content
- Tracking analytics based on user’s interaction on your product
- Trigger animation when user reaches, the viewport you have mentioned.
Lets deep dive into the intersection observer api :
The intersection observer api basically observes how much a target element is intersecting, with the viewport in which the parent or ancestor element is present.
- target element → the element we want to observe
- parent/root or ancestor element → the viewport in which we want to observe the target elements
- intersection ratio → how much the target element is intersecting with the root element , can be between 0 and 1.
Every target element that enters the viewport has the following properties:
Let's see the basic implementation of intersection observer api:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('Element is in view!');
}
});
}, {
root: null, // Use viewport as root
rootMargin: '50px', // Start loading 50px before element enters viewport
threshold: 0.1 // Trigger when 10% of element is visible
});
const target = document.querySelector('#myElement');
observer.observe(target);
Configuration Options provided by the api:
Let's breakdown the properties:
-
root: Defines the viewport for intersection calculations. The container element you want to observe visibility within.
-
rootMargin: Can be used for pre-loading content before it becomes visible. This is particularly useful for triggering actions before elements actually become visible.
-
threshold: Specifies at what percentage of visibility the callback should fire. You can provide a single value or an array for multiple trigger points. Value is between 0 and 1.

Benefits of using intersection observer api:
-
async operation, so they don’t block the main thread
-
Browser optimizes when callbacks run
-
No forced layout recalculations during scroll
-
much better performance and battery life
Things to Remember
- Be mindful of performance when observing many elements.
- Unobserver elements once you’re done (especially in lazy loading).
- Combine with requestIdleCallback or setTimeout for smoother loads.
Summary
The Intersection Observer API offers an optimized way to track how the target element becomes visible within a viewport. It allows you to define a root, margin, and threshold, and gives a callback only when these criteria are met, avoiding constant scroll event triggers. This helps in a very smooth implementation of infinite scrolling, without those laggy scrolling performance issues.