
Infinite Scroll: Stop Using 'scroll' Event. Use Intersection Observer.
Implementing infinite scroll with scroll listeners kills performance. Learn how to refactor with Intersection Observer API to reduce CPU usage by 90%.

Implementing infinite scroll with scroll listeners kills performance. Learn how to refactor with Intersection Observer API to reduce CPU usage by 90%.
Establishing TCP connection is expensive. Reuse it for multiple requests.

Tired of naming classes? Writing CSS directly inside HTML sounds ugly, but it became the world standard. Why?

Class is there, but style is missing? Debugging Tailwind CSS like a detective.

For visually impaired, for keyboard users, and for your future self. Small `alt` tag makes a big difference.

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."
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.
I understood it as "Guard vs. Motion Sensor (CCTV)."
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.
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>
);
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;
}
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.
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.
List[0], Create List[11].Combine Infinite Scroll (Fetching) + Virtual Scroll (DOM Recycling) for 60fps performance on massive lists.
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.
useLayoutEffect CorrectionYou 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.
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.
TanStack Query to keep previous data cached.scrollTop to sessionStorage on item click, and restore it on mount.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.
Beginners use Offset based pagination (page=1, page=2).
This causes Data Duplication in active feeds.
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.
ScrollControllerDon't manually calculate pixels using ScrollController.addListener().
if (offset >= maxScrollExtent - 200) is brittle.
Just use VisibilityDetector or Intersection Observer. It's declarative, cleaner, and performant.
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.
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.
Intersection Observer is also key for Lazy Loading.
Next/Image works this way.
"Observer detects image container -> Set src attribute."
Fast apps respect the CPU.