Intersection Observer Advanced Patterns: Lazy Load, Sentinel, Analytics
Master Intersection Observer beyond basic lazy-load — sentinel patterns, scroll analytics, React hooks, and performance traps that most tutorials skip entirely.
Why Most Intersection Observer Tutorials Are Wrong
The canonical tutorial shows you one thing: slap a new IntersectionObserver on an image, swap data-src into src, disconnect, done. That's fine for a blog post from 2019. It's not fine for a production React app with 400 components, a design system full of animated cards, and a product team that measures scroll depth in Mixpanel.
Intersection Observer landed in Chrome 51 (2016) and it's been broadly available without polyfills since around 2018. You probably don't need the intersection-observer npm package anymore. Check your browserslist config and drop it — you're paying a bundle-size tax for nothing.
Honestly, the API is deceptively small. One constructor, one callback, four options. But what you do *around* it — when you create observers, how many you reuse, when you disconnect, where you put thresholds — is where real production performance lives or dies. That's what this article covers.
We'll go through lazy loading done properly, the sentinel infinite-scroll pattern, reusable React hooks, and capturing scroll analytics without thrashing your event budget. Code examples are copy-paste ready. Let's get into it.
The Right Mental Model: Observers Are Expensive to Create, Cheap to Reuse
Here's the thing most devs get backwards. Creating a new IntersectionObserver per element — one for each image, one for each card — is expensive. The browser has to set up a new root-margin boundary box calculation for every single instance. In a list of 200 items, that's 200 observers. It will visibly lag on mid-range Android devices.
The correct pattern is one observer per *configuration* (same root, same rootMargin, same threshold), and then you .observe() multiple targets on that single instance. The callback receives an array of IntersectionObserverEntry objects — one per target — so you just iterate over them. Look, this alone can cut your observer overhead by 95% in a component-heavy app.
// BAD — one observer per element
elements.forEach(el => {
new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) handleVisible(el);
}).observe(el);
});
// GOOD — one observer, many targets
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) handleVisible(entry.target);
});
}, { rootMargin: '200px' });
elements.forEach(el => observer.observe(el));Worth noting: the rootMargin on the good example uses '200px' — that's a 200px expansion of the viewport boundary. Images start loading before they actually scroll into view, so users never see a blank frame. Tune this number based on your connection target. For a fast-connection US audience, 200px is fine. For emerging markets on 3G, push it to 500px.
One more thing — thresholds. threshold: 0 fires the callback the instant *any* pixel of the element crosses the root boundary. threshold: 1 fires only when the element is *fully* visible. For analytics (did the user actually read this section?) you want threshold: 0.5 or higher. For lazy loading, threshold: 0 is almost always right.
Building a Production-Ready useIntersectionObserver Hook
React makes the singleton-observer pattern a bit tricky because components mount and unmount independently. The cleanest approach is a module-level observer cache keyed by a serialized config string, stored outside React's render cycle entirely.
// hooks/useInView.ts
import { useEffect, useRef, useState } from 'react';
type Options = {
rootMargin?: string;
threshold?: number | number[];
once?: boolean;
};
// Module-level cache — survives re-renders
const observerCache = new Map<string, IntersectionObserver>();
function getObserver(
callback: IntersectionObserverCallback,
options: IntersectionObserverInit
): IntersectionObserver {
const key = JSON.stringify(options);
if (!observerCache.has(key)) {
observerCache.set(key, new IntersectionObserver(callback, options));
}
return observerCache.get(key)!;
}
export function useInView({
rootMargin = '0px',
threshold = 0,
once = true,
}: Options = {}) {
const ref = useRef<Element | null>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
if (once) observer.unobserve(el);
} else if (!once) {
setInView(false);
}
},
{ rootMargin, threshold }
);
observer.observe(el);
return () => observer.unobserve(el);
}, [rootMargin, threshold, once]);
return { ref, inView };
}The once flag is important. If you're triggering a CSS animation on scroll-in, you almost certainly want once: true — nobody wants their nav bar to re-animate every time they scroll back to the top. For analytics events, you'll want once: false and manual deduplication on the analytics side.
Quick aside: notice there's no useCallback on the observer callback in the example above. That's intentional — the observer is recreated on rootMargin/threshold changes via the dependency array, which is the right behavior. Memoizing the callback without also memoizing the observer creation would give you stale closure bugs that are genuinely painful to debug.
Usage looks like this:
``tsx
function AnimatedCard({ children }: { children: React.ReactNode }) {
const { ref, inView } = useInView({ rootMargin: '0px', threshold: 0.2 });
return (
<div
ref={ref}
className={transition-all duration-700 ${
inView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
}}
>
{children}
</div>
);
}
``
This is exactly the kind of component you'd build on top of Empire UI styled primitives — the animation tokens slot right in.
Infinite Scroll with the Sentinel Pattern
Infinite scroll has a bad reputation — mostly because devs implement it badly. The sentinel pattern fixes that. Instead of listening to scroll events (which fire at 60fps and murder your main thread), you drop a tiny invisible element at the bottom of your list and observe *that*. When the sentinel enters the viewport, you fetch the next page.
function InfiniteList() {
const [items, setItems] = useState<Item[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!sentinelRef.current || !hasMore) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
fetchPage(page).then(({ data, totalPages }) => {
setItems(prev => [...prev, ...data]);
setPage(p => p + 1);
if (page >= totalPages) setHasMore(false);
});
}
},
{ rootMargin: '300px' } // preload 300px before bottom
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [page, hasMore]);
return (
<>
{items.map(item => <Card key={item.id} {...item} />)}
{hasMore && <div ref={sentinelRef} style={{ height: 1 }} />}
</>
);
}That 1px sentinel div is doing a lot of work. It's invisible, it has no layout impact, and the observer fires a fetch 300px *before* it scrolls into view — so pages load ahead of time rather than after a jarring pause. In practice, this feels identical to native scroll on a fast connection. The pattern also degrades gracefully: if the sentinel doesn't exist (no more pages, or SSR without hydration), the observer simply has nothing to watch.
One common mistake: reconnecting a new observer every time page changes. You can see in the example above that the useEffect cleanup calls observer.disconnect(), and the effect re-runs with the new page. That's correct, but only because we have page in the dependency array. Miss that and your observer captures a stale closure and fetches the same page repeatedly. Been there.
That said, for large lists you might want to combine this with React's startTransition to keep the UI responsive while the state update (adding 20 new items) processes. Wrap setItems and setPage in startTransition(() => { ... }) and React will defer those re-renders during any concurrent work happening in parallel. Small change, big UX difference on slower hardware.
Scroll Analytics Without Killing Performance
Product teams love scroll depth analytics. Did users see the pricing section? Did they reach the CTA? The naive implementation — a scroll event listener that fires analytics.track() at every pixel — is a performance disaster. The right implementation is Intersection Observer with a threshold array and a ref-guarded fire-once map.
function useScrollAnalytics(sectionId: string) {
const ref = useRef<HTMLElement | null>(null);
const fired = useRef(new Set<number>());
useEffect(() => {
const el = ref.current;
if (!el) return;
// Fire at 25%, 50%, 75%, 100% visibility
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const pct = Math.round(entry.intersectionRatio * 100);
const bucket = [25, 50, 75, 100].find(b => pct >= b && !fired.current.has(b));
if (bucket) {
fired.current.add(bucket);
analytics.track('section_viewed', {
section: sectionId,
depth_pct: bucket,
});
}
});
},
{ threshold: [0, 0.25, 0.5, 0.75, 1.0] }
);
observer.observe(el);
return () => observer.disconnect();
}, [sectionId]);
return ref;
}The fired ref is the critical piece. Without it, scrolling back and forth through a section re-fires 25% and 50% events repeatedly, polluting your analytics with duplicates. The Set ensures each depth bucket fires exactly once per page load, per section.
Honestly, this pattern has replaced scroll-depth plugins for me entirely. No extra library, no global event listeners, no debouncing gymnastics. The observer fires at exactly the thresholds you care about and nowhere else. Each observation is cheap — a single callback invocation vs. dozens of scroll event calls per second.
You can wire this up across every major content block in a landing page in under 30 minutes. Pair it with the animated sections from your design system (the ones built on top of glassmorphism components or your chosen style hub) and you've got full-funnel scroll visibility with zero performance cost.
Lazy Loading Images and Components the Right Way
Native loading="lazy" on <img> exists and you should use it for basic cases. Full stop. But it doesn't work for background images, SVG sprites, or component-level lazy loading where you want to defer rendering an entire subtree until it's near the viewport. For those, you need the observer.
function LazySection({ children }: { children: React.ReactNode }) {
const { ref, inView } = useInView({ rootMargin: '400px', once: true });
return (
<div ref={ref}>
{inView ? children : <div style={{ minHeight: 200 }} />}
</div>
);
}
// Usage — heavy chart component only mounts when near viewport
<LazySection>
<HeavyAnalyticsChart data={chartData} />
</LazySection>The minHeight placeholder is not optional. Without it, your page layout collapses until the component mounts, triggering a layout shift (CLS hit) right as the user is about to see the content. Set the height to something close to what the component will render. It doesn't have to be exact — even a rough estimate cuts CLS to near zero.
For background images specifically, the pattern is slightly different — you're adding a class to trigger a CSS background-image load rather than swapping a src attribute:
``tsx
function LazyHero() {
const { ref, inView } = useInView({ rootMargin: '100px', once: true });
return (
<div
ref={ref}
className={hero-section ${
inView ? 'bg-loaded' : 'bg-placeholder'
}}
/>
);
}
`
Your CSS then puts the real background only on .bg-loaded`. The browser won't fetch the image until that class is applied. This is how you keep LCP from being polluted by off-screen hero images in long-scrolling pages.
That said, don't go overboard. Lazy-loading every single component adds complexity and can actually hurt performance when components are small and fast to render. The sweet spot is anything that makes network requests (images, charts, maps, embeds) or runs expensive JavaScript on mount. Purely structural components — cards, grids, text blocks — should render normally. You can explore component patterns for both approaches in the Empire UI library.
Common Pitfalls and How to Dodge Them
The rootMargin unit trap. rootMargin only accepts px and % — no rem, no vh. Passing '10rem' will silently fail in most browsers and fall back to 0px. Check your observer options in DevTools (there's an Intersection Observer panel in Chrome since version 90) if callbacks aren't firing when you expect.
SSR and window availability. If you're on Next.js or any SSR framework, new IntersectionObserver inside a useEffect is fine because effects only run client-side. But if you ever instantiate an observer at module level or in a server component, it will throw. Keep all observer creation inside useEffect or a lazy initializer.
Stale callback references. Because IntersectionObserver takes a callback at construction time, that callback closes over whatever was in scope when the observer was created. In React, this means if your callback references state or props without including those in the useEffect dependency array, you'll get stale data bugs. Either put everything in the dep array, or use a ref to hold the latest callback:
``tsx
const callbackRef = useRef(callback);
useEffect(() => { callbackRef.current = callback; });
const stableCallback = useCallback((entries) => callbackRef.current(entries), []);
``
Disconnect vs. unobserve. observer.unobserve(el) removes one target from the observer's watch list but keeps the observer alive for other targets. observer.disconnect() nukes the entire observer. Use unobserve when you've handled one element and want to keep watching others (like in a lazy-load list). Use disconnect in cleanup when the entire component unmounts. Mixing them up causes subtle memory leaks — elements stay observed after their components are gone.
One more thing — the isIntersecting vs. intersectionRatio > 0 distinction. They're almost identical but not quite. At threshold: 0, isIntersecting becomes true the moment any pixel crosses the boundary, same as intersectionRatio > 0. But if you're using multiple thresholds, isIntersecting can be true even when the element is scrolling *out* of view (if it's still partly visible). For accurate analytics, check the intersectionRatio against your expected bucket directly rather than relying solely on isIntersecting.
FAQ
Yes, and you should. Call .observe(el) on the same observer instance for each element. The callback fires with an array of entries — one per element that changed visibility. Creating a separate observer per element is a common performance mistake that compounds badly in large lists.
It does, but you have to set the root option to the scrollable container element instead of leaving it as null (which defaults to the viewport). Pass { root: scrollContainerRef.current } when constructing the observer, otherwise it'll never fire inside a non-document scroll container.
Almost always a SSR issue or a missing dependency in useEffect. Make sure you're creating the observer inside useEffect, not at module scope. Also double-check that the element ref is actually populated when the effect runs — add a guard for if (!ref.current) return.
Night and day. Scroll events fire synchronously on the main thread — up to hundreds of times per second without throttling. IntersectionObserver runs off the main thread and batches callbacks. For scroll-triggered UI work, the observer is strictly better and you'll see it clearly in your Lighthouse performance scores.