Topic: Web platform
IntersectionObserver: visibility, loading, and virtualization are separate jobs
Learn root, rootMargin, threshold, initial notifications, and observer cleanup with small JavaScript examples. Separate infinite-scroll request control from virtualization and its DOM budget.
Animated meme (expand/collapse)
A product list should fetch another batch before you reach the bottom. A card should run an effect when it enters the viewport. Both start with a similar question: has this element reached the relevant area?
IntersectionObserver reports geometric intersections and threshold crossings. It does not download products or turn ten thousand cards into ten mounted elements.
I would separate those responsibilities before tuning the options.
1. Stop calculating every position on every scroll
A common approach listens for scroll and repeatedly calls getBoundingClientRect() for each card. IntersectionObserver lets the browser manage intersection detection and notify a callback asynchronously.
That does not move your callback to a background thread. Its JavaScript still runs on the main thread. Expensive sorting or large DOM updates there can still stall interaction. The API changes detection, not the cost of the work that follows. MDN: intersection callbacks
It suits questions such as “are we approaching the list’s end?” or “did this card cross the half-area threshold?” It is not primarily a tool for synchronizing an effect with every exact scroll pixel.
2. Start with three options
| Option | Question it answers | Example |
|---|---|---|
root |
Which region do we compare against? | null uses the ordinary page viewport; a scrolling panel can be an element root. |
rootMargin |
How much do we expand or shrink the root’s calculated bounds? | "0px 0px 300px 0px" extends the bottom by 300px. |
threshold |
Which fraction of the target should trigger a crossing notification? | 0.5 means half the target’s area, not half the viewport height. |
An element root must be an ancestor of the target. With a scrolling panel as root, do not expect comparisons against the entire browser viewport. MDN: creating an observer
Positive rootMargin values expand the calculated bounds; negative values shrink them. They do not change CSS layout. Adding 300px at the bottom can give advance notice, but ancestor clipping and other geometry still matter. It promises no particular number of seconds. MDN: rootMargin
For an ordinary target with nonzero area, intersectionRatio is intersection area divided by target area. A card too tall to fit inside the root may never reach threshold: 1; that is a poor mandatory start condition for such a target. MDN: intersectionRatio
The constructor option is singular threshold; the observer’s readable list is plural thresholds. Use threshold: [0, 0.5, 1] for several crossings. Crossings work in both directions, not only on entry. MDN: thresholds
3. A runnable first example: mark each card once
Put this HTML in an empty page, followed by the JavaScript. The text remains readable even without the API; the enhancement does not hide it.
<p style="min-height: 120vh">Scroll down to see when the notification arrives.</p>
<article data-card>First card</article>
<article data-card>Second card</article>
const cards = document.querySelectorAll("[data-card]");
if ("IntersectionObserver" in window) {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.dataset.seen = "true";
console.log("Reached:", entry.target.textContent);
observer.unobserve(entry.target);
}
}, {
root: null,
rootMargin: "0px 0px 300px 0px",
threshold: 0,
});
cards.forEach((card) => observer.observe(card));
}
One observer can watch several targets, and one callback can contain several entries. Process the batch, not only entries[0]. MDN: observe()
Observation also produces an initial notification when the target has not moved or is outside the viewport. A callback running is therefore not evidence of entry. Check isIntersecting; it describes intersection, not a completed image download or a reader’s attention. MDN: isIntersecting
This example deliberately expands the root in advance. Its seen value is a demonstration marker, not a reading record. Default intersection detection does not establish that other content has not covered the target. Exposure analytics need separate definitions for time, occlusion, consent, and business rules.
4. Cleanup and newly added cards
unobserve(target) stops watching one target, which prevents repeat logging in the example. disconnect() stops all targets for that observer, useful when a component unmounts. Neither removes DOM elements. MDN: disconnect()
Configuration is fixed at construction. Mutating the original options object does not reconfigure an observer. Create another observer for different bounds or thresholds. Newly inserted cards are not automatically registered either; call observe() for them. MDN: IntersectionObserver
Stopping observation does not cancel a fetch already started by a callback. Manage AbortController separately and prevent results from updating an unmounted UI.
5. Infinite scrolling needs request control
Place a small sentinel at the end of the list. Its intersection can call the loading function. A Load more button can call the same function, preserving manual operation and a retry path.
The observer does not await an async callback or serialize requests. Guard the loading function first:
let loading = false;
let hasMore = true;
async function loadNextPage() {
if (loading || !hasMore) return;
loading = true;
try {
const page = await fetchNextPage();
appendRows(page.items);
hasMore = page.hasMore;
status.textContent = hasMore ? "More available" : "End of list";
} catch (error) {
status.textContent = "Could not load. Use Load more to retry.";
console.error(error);
} finally {
loading = false;
}
}
This is a state-control fragment, not a complete API client. The application supplies fetchNextPage(), appendRows(), and the status element. Fetching must check HTTP status, validate the response, and maintain a cursor before returning { items, hasMore }. Rendering must use safe DOM or framework operations, not insert untrusted text through innerHTML. Status text can use an aria-live="polite" region.
Set loading = true before the first await. Setting it after the response leaves a window for another notification or button click to start a duplicate request. A failed fetch does not mean the end of the list, so do not set hasMore to false in catch. MDN: using fetch
If the sentinel stays intersecting, each completed fetch is not guaranteed to generate another notification. A list that does not yet fill the viewport needs an explicit fill strategy, not an unbounded automatic retry loop.
6. Virtualization keeps data while limiting rendered rows
Infinite scrolling answers when to fetch the next batch. Appending every batch indefinitely also grows the DOM. IntersectionObserver does not reclaim those rows.
Virtualization, also called windowing, mainly renders the visible range plus a buffer before and after it. Rows outside that range can be removed or reused. The underlying data may remain available; no database records are deleted. web.dev: list virtualization
Animated meme (expand/collapse)
Consider hypothetical numbers: ten thousand rows, each exactly 80px tall, in a 480px container. At an aligned position, six rows fit. A buffer of three rows on either side gives roughly twelve rendered rows rather than ten thousand.
Scrolling to the middle must not erase the space before those rows. Implementations usually preserve total height and row positions so the scrollbar still represents the full list, then render a different range as you move. Boundaries, variable heights, and buffer policy change the actual count. Twelve is neither a library default nor a performance guarantee.
| Mechanism | Responsibility | Not solved automatically |
|---|---|---|
| IntersectionObserver | Notify geometric intersection changes | Fetches, request ordering, DOM limits |
| Infinite-scroll loading | Obtain additional data in batches | Accumulation of already rendered rows |
| Virtualization | Limit the rendered range and DOM size | Network requests or data memory limits |
These mechanisms can cooperate or be used separately. An administration screen with ten thousand results may need pagination and predictable navigation first, not infinite scrolling. Virtualization also needs checks for keyboard focus, screen readers, row heights, in-page search, and restored scroll position.
The linked web.dev article dates from 2019. This guide uses its windowing principle, not its react-window examples as current package API or installation advice.
7. For ordinary images, start simpler
To defer ordinary offscreen images, first consider native loading="lazy", with width and height to reserve space. Use an observer when custom conditions or additional work justify it. Do not routinely lazy-load important initial-viewport images, particularly a likely LCP image. web.dev: browser-level image lazy loading
What I learned
- I would decide the comparison region, advance distance, and area threshold before interpreting a callback as evidence that someone saw content.
- I would separate intersection notifications, fetching, and rendering, setting loading state before await. Observer cleanup is not request cancellation.
- I would budget data and DOM independently. Virtualization limits rendering; it does not replace pagination, caching, or usable navigation.
Start with two cards and one observer. Watch the initial notification, change rootMargin once, then compare a different threshold. Be able to explain why a notification arrived before connecting it to a real loading workflow.
References and further learning
- MDN Intersection Observer API: concepts and compatibility. This guide uses basic functionality, not every extension option.
- Web Dev Simplified’s original guide: concrete introductory examples of notifications and configuration.
- Learn Intersection Observer In 15 Minutes: the same author’s introductory video, useful alongside an empty practice page. Current documentation remains the semantic reference.
- web.dev list virtualization: windowing principles; account for the age of the article and package examples.