Infinite Scroll: Stop Using 'scroll' Event. Use Intersection Observer.
1. "Every Scroll Causes Logic Lag"
I built an Instagram-like feed.
I naively used window.addEventListener('scroll', handler).
"If scroll hits bottom, fetch more."
But after 100+ items, scrolling became choppy (Jank). Mobiles overheated. My lead dev sighed: "Who attaches listeners directly to scroll events these days? Even throttling isn't enough. Use an Observer."
2. What Confused Me Initially? (Event Spam)
I underestimated that "Scroll events fire per pixel."
One swipe fires hundreds of events.
If you run getBoundingClientRect() inside, the browser chokes on Reflow (Layout Calculation).
Even with underscore.throttle(func, 200), calculation checks like scrollTop + clientHeight >= scrollHeight are expensive because they force the browser to recalculate layouts synchronously.
3. The 'Aha!' Moment (CCTV Analogy)
I understood it as "Guard vs. Motion Sensor (CCTV)."
- Scroll Event: A Guard pacing back and forth 100 times a second checking "Is anyone here?" (Wasteful)
- Intersection Observer: A Motion Sensor. It sleeps until "Someone crosses the line." Then it pings you. (Efficient)
We don't care how much scrolled. We only care "Is the bottom transparent line visible?" Intersection Observer optimizes this at the C++ browser level.
4. The Fix: Switching to Observer
Step 1: The 'Sentinel' Div
Place an empty, invisible element at the end of your list.
return (
<div>
{items.map(item => <Post key={item.id} data={item} />)}
{/* Sentinel */}
<div ref={ref} style={{ height: '20px', background: 'transparent' }} />
</div>
);
Step 2: Custom Hook
import { useEffect, useRef } from 'react';
export function useIntersectionObserver(callback: () => void) {
const targetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!targetRef.current) return;
const observer = new IntersectionObserver((entries) => {
// if passing the threshold
if (entries[0].isIntersecting) {
callback();
}
}, { threshold: 0.5 });
observer.observe(targetRef.current);
return () => observer.disconnect();
}, [callback]);
return targetRef;
}
Step 3: Usage
const loadMore = () => {
if (!isLoading) fetchNextPage();
};
const ref = useIntersectionObserver(loadMore);
return (
<div>
{/* ... list ... */}
<div ref={ref}>Loading...</div>
</div>
);
Now, scroll all you want. JS sleeps.
loadMore fires ONLY when the bottom element peeks into view.
5. Deep Dive: Virtual Scroller
With 1,000+ items, just having DOM nodes crashes memory.
Use Virtual Scroll (Windowing) (react-window, tanstack-virtual).
It renders only the 10 items in view and fakes the rest with empty padding.
- Scroll down -> Destroy
List[0], CreateList[11]. - Memory usage stays constant.
Combine Infinite Scroll (Fetching) + Virtual Scroll (DOM Recycling) for 60fps performance on massive lists.
6. Deep Dive: Bi-directional Scroll (Chat Apps)
Chat apps require scrolling UP to load history, and DOWN to load new messages. You need two Sentinels: one at the top, one at the bottom.
The Hardest Part: Scroll Jumping. If you prepend 20 items, the list height grows, and your viewport position is pushed down physically. Visually, the user loses their spot.
The Fix: useLayoutEffect Correction
You must adjust the scroll position synchronously after the DOM update but before the browser paints.
// Before update
const oldHeight = listRef.current.scrollHeight;
const oldTop = listRef.current.scrollTop;
// After update (Before Paint)
useLayoutEffect(() => {
const newHeight = listRef.current.scrollHeight;
// Shift scroll down by the amount of added height
listRef.current.scrollTop = oldTop + (newHeight - oldHeight);
}, [messages]);
This makes the new messages appear "above" without looking like the screen jumped. The user thinks they are staying still.
7. Case Study: Back Button Reset (Scroll Restoration)
I scrolled down to item #100, clicked it, then pressed 'Back'. The list reloaded from item #1, sending me to the top. This is bad UX.
Why? Component remount triggers useEffect which resets the list state.
Solution:
- Global Store: Keep list data in Redux/Zustand so it survives unmount.
- Cache: Use
TanStack Queryto keep previous data cached. - Scroll Position: Save
scrollToptosessionStorageon item click, and restore it on mount.
8. Case Study: The 100,000 DIV Crash
A true story. We had an admin dashboard streaming logs.
A user left the page open overnight.
Streaming logs kept appending <div>s.
Next morning? "Aw, Snap!" (Chrome Crash).
The DOM had 100,000 nodes, eating 1.5GB RAM.
Fix:
We switched to react-virtuoso.
Even with 1 million logs, it only renders the 50 lines visible on screen.
RAM usage dropped to 50MB.
Lesson: Never render what you can't see.
9. Architecture: Cursor vs Offset Pagination
Beginners use Offset based pagination (page=1, page=2).
This causes Data Duplication in active feeds.
Scenario:
- You load Page 1 (Items 1-10).
- 5 new items are posted globally.
- You request Page 2.
- The server shifts everything. Items 6-10 are now part of Page 2.
- You see Items 6-10 AGAIN.
The Fix: Cursor Based Pagination Request "Give me 10 items AFTER ID #1283". This is robust against real-time inserts/deletes. Always ask your Backend Engineer for Cursor Pagination.
10. Anti-Pattern: Obsessing over ScrollController
Don't manually calculate pixels using ScrollController.addListener().
if (offset >= maxScrollExtent - 200) is brittle.
- It fires 60 times/sec (Needs Throttling).
- Variable height items break the math.
Just use VisibilityDetector or Intersection Observer. It's declarative, cleaner, and performant.
12. Tip: The "Scroll to Top" Button
After scrolling down 50 pages, scrolling back up is painful. Show a Floating Action Button (FAB) when the user scrolls past 500px.
// FloatingActionButton
FloatingActionButton(
onPressed: () {
scrollController.animateTo(
0,
duration: Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
},
child: Icon(Icons.arrow_upward),
)
UX Decision: Should this action refresh the data? Usually, No. Just scroll to top. Let the user pull-to-refresh if they want new data. Mixing navigation with data fetching confuses users.
11. FAQ
Q: My Observer triggers instantly on load.
A: Your sentinel might be naturally visible if the list is empty or short. Disable the observer while isLoading is true, or check for data length.
Q: Images flicker.
A: Layout Shift. Give your image containers a fixed height or aspect-ratio so the sentinel doesn't jump around as images load.
9. Application: Lazy Image Loading
Intersection Observer is also key for Lazy Loading.
Next/Image works this way.
"Observer detects image container -> Set src attribute."
10. Summary
- Scroll listeners are CPU thieves.
- Delegate 'Bottom Detection' to Intersection Observer.
- Stop calculating pixels; just detect visibility.
- Use Virtualization for large lists.
Fast apps respect the CPU.