Problem statement
To build a high-performance UI for a large list of cards. We had to handle a dynamic dataset that could grow up to 1000 items.
Each card included:
- A high-resolution image
- A title
- A few additional metadata fields
As the number of cards increased, the page began to lag significantly. This was amplified by other concurrent features such as:
- Countdown timers
- Real-time updates
- Frequent UI state changes
The challenge was to keep scrolling smooth and interactions responsive while managing heavy rendering workloads.
What was tried
1. Windowed pagination (loading 50 items at a time)
The first approach was to load only a fixed number of cards (e.g., 50) at a time.
As the user scrolled down:
- New data was fetched and appended at the bottom
- Older items were removed from the top to keep the total count constant
const PAGE_SIZE = 50;
const loadNextBatch = async () => {
const nextItems = await fetchPage(currentPage + 1);
setItems(prev =>
[...prev.slice(PAGE_SIZE), ...nextItems]
);
};
Issues with this approach:
- State logic became complex due to constant add/remove operations
- Scroll position needed manual correction to avoid jumps
- Users noticed content shifting when items were removed from the top
- Edge cases (fast scrolling, slow networks) caused instability
2. Infinite scroll
Infinite scrolling was introduced to make content loading feel natural.
const onScroll = () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 200) {
loadNextPage();
}
};
Issues with this approach:
- Over time, all cards were mounted in the DOM
- Memory usage kept increasing due to high-resolution images
- Performance degraded again near the end of the list
What worked
Viewport-based rendering (manual virtualization)
Instead of rendering everything, a selective rendering strategy was implemented:
- Only the 6 cards fully visible in the viewport were rendered
- Additionally, 3 cards above and 3 cards below the viewport were rendered as a buffer
- All other cards were replaced with lightweight shimmer placeholders
To detect which cards entered or exited the viewport, IntersectionObserver was used instead of scroll math.
const observer = new IntersectionObserver(callback, {
root: null,
rootMargin: "300px", // buffer for top & bottom cards
threshold: 0.1,
});
const callback: IntersectionObserverCallback = (entries) => {
entries.forEach(entry => {
const index = Number(entry.target.getAttribute("data-index"));
setVisibleMap(prev => ({
...prev,
[index]: entry.isIntersecting,
}));
});
};
Attaching the observer:
<div
ref={el => el && observer.observe(el)}
data-index={index}
>
{isVisible ? <Card data={item} /> : <CardShimmer />}
</div>
To avoid rapid state updates during scroll, visibility updates were debounced:
const updateVisibility = debounce(setVisibleMap, 100);
Benefits:
- Drastically reduced DOM complexity
- Prevented sudden blank areas during fast scrolls
- Maintained perceived continuity using shimmer placeholders
Using virtualization libraries (recommended solution)
This manual solution was later replaced with battle-tested libraries:
- react-window
- Lightweight and performance-focused
- Ideal for large lists and grids
- react-virtualized
- Feature-rich with advanced layout support
- Slightly heavier but very flexible
Example using react-window
import { FixedSizeList as List } from "react-window";
<List
height={600}
itemCount={items.length}
itemSize={220}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<Card data={items[index]} />
</div>
)}
</List>
Final outcome / learning
- Rendering many items is not the issue
- Rendering many DOM nodes at once is
- Viewport-based rendering is essential for large lists
- Manual virtualization works but adds complexity
- Libraries like
react-windowandreact-virtualizedprovide cleaner, scalable solutions
By switching to virtualization, the UI stayed smooth and responsive, even with real-time updates and timers running continuously.
Resources
- react-window
Lightweight and highly performant library for rendering large lists and grids.
Best choice when item sizes are predictable and simplicity matters. - react-virtualized
A more feature-rich virtualization library with support for complex layouts, dynamic sizing, and advanced use cases.
If you’ve solved similar performance challenges in a different way, I’d love to hear your approach. There’s always room to improve how we build smooth, scalable UIs.