5.4 — Long lists & virtualization¶
Module 5 · Lesson 4 · 🟡 Intermediate · ~35 min
What you'll learn¶
- When a long list is actually a problem, and when virtualization is overkill
- Virtualizing a responsive product grid (harder than a fixed‑height list)
- Infinite scroll that doesn't destroy INP, memory, or SEO
- The cheap alternatives that often beat virtualization on a commerce site
When lists become a problem¶
| List size | Symptom | Action |
|---|---|---|
| < 50 items | None | Do nothing |
| 50–200 | Slow filter/sort interactions; large DOM | memo + content-visibility |
| 200–1,000 | Visible jank; layout cost; memory | Virtualize, or paginate |
| 1,000+ | Unusable on mobile; possible crashes | Virtualize, always |
The costs that scale with list length:
- React render time — 48 cards × 7.6 ms = 365 ms per re‑render
- DOM node count — 48 × 25 nodes = 1,200 nodes; style recalc scales with this
- Layout cost — the browser lays out every node
- Memory — images, event listeners, and fiber nodes
- Image decoding — even lazy images decode when they enter the viewport
The most common commerce mistake is virtualizing a 48‑item PLP. 48 items is not a virtualization problem; it's a re‑render problem. Fix the re‑render (5.3) before adding a windowing library.
Cheap alternatives, tried first¶
1. Paginate¶
Server‑side pagination with URL state is simpler, faster, cacheable, SEO‑friendly, and it's what most shoppers expect. 24–48 products per page.
// app/c/[slug]/page.tsx
export default async function CategoryPage({ params, searchParams }) {
const { slug } = await params;
const { page = '1' } = await searchParams;
const results = await searchProducts({ category: slug, page: Number(page), pageSize: 48 });
return (
<>
<ProductGrid products={results.items} />
<Pagination
current={results.page}
total={results.totalPages}
buildHref={(p) => `/c/${slug}?page=${p}`}
/>
</>
);
}
Pagination beats infinite scroll for commerce in most cases: it's cacheable per page, gives the crawler distinct URLs, keeps the footer reachable, and avoids unbounded memory growth. Use infinite scroll only where user research says it converts better for your audience — and measure it.
2. content-visibility¶
Zero JavaScript, works with the normal DOM, skips rendering work for off‑screen items:
On a 200‑tile grid this cuts initial layout time substantially — typically −80 to −250 ms on mid‑tier mobile — for a two‑line CSS change. Never apply it above the fold (2.5).
3. Render less per item¶
A product card with 25 DOM nodes and four conditional badges costs more than one with 12. On a 200‑item grid that difference is the whole problem.
// ❌ 25 nodes per card
<article>
<div><div><div className="relative"><Image /></div></div></div>
<div><div><span><span>{name}</span></span></div></div>
{/* … */}
</article>
// ✅ 11 nodes, same visual result with CSS grid/flex
<article className="grid gap-2">
<Image />
<h3>{name}</h3>
<PriceDisplay value={price} />
{badge && <Badge>{badge}</Badge>}
</article>
Virtualization¶
When you genuinely have 500+ items in a single scroll container, virtualize: render only the visible window plus a small overscan.
// components/virtual-product-grid.tsx
'use client';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef, useState, useEffect } from 'react';
export function VirtualProductGrid({ products }: { products: Product[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState(4);
// Responsive column count — the hard part of grid virtualization
useEffect(() => {
const el = parentRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const w = entry.contentRect.width;
setColumns(w < 640 ? 2 : w < 1024 ? 3 : w < 1536 ? 4 : 5);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const rowCount = Math.ceil(products.length / columns);
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => 420, // row height including gap
overscan: 3, // rows rendered beyond the viewport
});
return (
<div ref={parentRef} className="h-[calc(100vh-200px)] overflow-auto">
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const start = virtualRow.index * columns;
const rowItems = products.slice(start, start + columns);
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement} // measure real heights
style={{
position: 'absolute',
top: 0, left: 0, width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
className="grid gap-4"
// eslint-disable-next-line react/forbid-dom-props
css={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
>
{rowItems.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
);
})}
</div>
</div>
);
}
The problems virtualization creates¶
Be honest about these before adopting it:
| Problem | Mitigation |
|---|---|
| SEO: crawlers see only the first window | Server‑render the first page unvirtualized; paginate for crawlers |
| Ctrl+F doesn't find off‑screen items | Accept it, or provide in‑page search |
| Scroll restoration breaks | Save/restore scrollTop + the item index on navigation |
| Variable heights cause jumps | Use dynamic measurement (measureElement); estimate generously |
| Fixed container height required | Conflicts with natural page scroll; window virtualizers help |
| Accessibility: screen readers lose list context | aria-setsize / aria-posinset on items |
| Anchor links / deep links to items | Provide a scroll‑to‑index API |
// Accessibility for a virtualized list
<div role="list" aria-label="Products">
{virtualItems.map((v) => (
<div
key={v.key}
role="listitem"
aria-setsize={products.length}
aria-posinset={v.index + 1}
>
<ProductCard product={products[v.index]} />
</div>
))}
</div>
On a commerce PLP, prefer window‑scroll virtualization (useWindowVirtualizer) over a fixed
container. A fixed‑height inner scroll container on a product grid breaks the browser's natural
scrolling, sticky headers, and scroll restoration — and shoppers hate it.
Infinite scroll, done properly¶
If product research says infinite scroll converts better for you, implement it so it doesn't degrade over time.
// components/infinite-product-grid.tsx
'use client';
import { useEffect, useRef, useCallback, useTransition } from 'react';
export function InfiniteProductGrid({
initialProducts, categorySlug, totalPages,
}: Props) {
const [products, setProducts] = useState(initialProducts);
const [page, setPage] = useState(1);
const [isPending, startTransition] = useTransition();
const sentinelRef = useRef<HTMLDivElement>(null);
const loadingRef = useRef(false);
const loadMore = useCallback(async () => {
if (loadingRef.current || page >= totalPages) return;
loadingRef.current = true;
const next = await fetchProducts(categorySlug, page + 1);
startTransition(() => {
setProducts((prev) => [...prev, ...next.items]);
setPage((p) => p + 1);
});
loadingRef.current = false;
// Keep the URL in sync so refresh/share/back all work
history.replaceState(null, '', `?page=${page + 1}`);
}, [categorySlug, page, totalPages]);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(
([e]) => { if (e.isIntersecting) void loadMore(); },
{ rootMargin: '800px' }, // load before the user reaches the bottom
);
io.observe(el);
return () => io.disconnect();
}, [loadMore]);
return (
<>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{products.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
{/* Reserved space so the sentinel appearing doesn't shift layout */}
<div ref={sentinelRef} className="h-20" aria-hidden />
{isPending && <LoadingRow />}
{/* Always provide a real link for crawlers and keyboard users */}
{page < totalPages && (
<a href={`?page=${page + 1}`} className="sr-only focus:not-sr-only">
Load more products
</a>
)}
</>
);
}
The five rules of commerce infinite scroll:
- Cap it. After 5–10 pages, switch to a "Load more" button or pagination. Unbounded lists exhaust memory on mobile and make INP degrade monotonically as the session continues.
- Keep the URL in sync (
history.replaceState) so refresh and share work. - Provide a real paginated fallback for crawlers and keyboard users.
- Reserve space for the loading row or you get CLS on every load.
- Make the footer reachable — a sticky "back to top" and a footer that appears after the cap.
The memory trap: at page 10 with 480 products, you have ~12,000 DOM nodes and 480 images in memory. Interaction latency degrades continuously, and it won't show up in your synthetic tests because those load one page. Combine infinite scroll with virtualization above ~200 items, or cap it.
Images in long lists¶
Images dominate the cost of a product grid.
// ✅ Correct pattern for a grid tile
<Image
src={product.image}
alt={product.name}
width={400}
height={533}
// Real rendered widths per breakpoint — see 2.1
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
// Above-the-fold tiles eager, everything else lazy (the default)
priority={index < 4}
// Decode off the main thread
decoding="async"
// Flat placeholder, not a blur (48 base64 blobs bloat the HTML — see 2.1)
className="bg-neutral-100"
/>
Watch for decode cost: even lazy‑loaded images decode on the main thread when they enter the
viewport. On a fast scroll through 200 tiles that's a lot of decoding. decoding="async" helps;
smaller images help more.
Tables (order history, admin grids)¶
Same principles, plus:
// Virtualized table with a sticky header
<div className="h-[600px] overflow-auto">
<table className="w-full">
<thead className="sticky top-0 z-10 bg-white">…</thead>
<tbody style={{ height: totalSize, position: 'relative' }}>
{virtualRows.map((v) => (
<tr key={v.key} style={{ position: 'absolute', top: 0, transform: `translateY(${v.start}px)`, width: '100%' }}>
…
</tr>
))}
</tbody>
</table>
</div>
position: absolute on <tr> breaks table layout — you'll need table-layout: fixed with
explicit column widths, or use a div‑based grid with role="table". This is a common source of
bugs; budget time for it.
Aurora's PLP results¶
| Approach | Products rendered | Filter INP | DOM nodes | Memory |
|---|---|---|---|---|
| Baseline (48/page, no memo) | 48 | 410 ms | 1,240 | 42 MB |
+ memo + stable props |
48 | 240 ms | 1,240 | 42 MB |
+ content-visibility |
48 | 195 ms | 1,240 | 38 MB |
+ URL filters + startTransition |
48 | 118 ms | 1,240 | 38 MB |
| Virtualized (500 items loaded) | 15 | 96 ms | 420 | 28 MB |
They did not ship virtualization for the 48‑item default view — the gains came from memoization,
state architecture, and content-visibility. Virtualization was enabled only for the "show 500"
view that power users toggle.
That's the right conclusion: virtualization is a real tool with real costs, and it's the last option, not the first.
Common mistakes¶
| Mistake | Cost |
|---|---|
| Virtualizing a 48‑item grid | Complexity, broken SEO/Ctrl+F, no measurable gain |
| Virtualizing before fixing re‑renders | Treats the symptom |
| Fixed‑height scroll container on a PLP | Breaks natural scroll, sticky headers, scroll restoration |
| Unbounded infinite scroll | Memory growth; INP degrades through the session |
| No paginated fallback | Crawlers see one page; long‑tail SEO dies |
| Blur placeholders on every tile | Bloated HTML; slower LCP |
Wrong sizes on grid images |
2–4× the bytes (2.1) |
| Losing scroll position on back navigation | The single most‑complained‑about commerce UX bug |
Lab 5.4 — List performance¶
- Count. How many items render on your PLP by default? If under 100, virtualization is probably not your fix.
- Profile a filter change in the React Profiler. Note the commit time and the number of cards rendered.
- Apply the cheap fixes in order: stable props +
memo,content-visibility, fewer DOM nodes per card, URL filters +startTransition. Measure after each. - Only then, if you still have 500+ items in one container, virtualize — and test SEO, Ctrl+F, scroll restoration, and screen‑reader announcement afterwards.
- Test a long session: load 10 pages of infinite scroll, then measure INP and heap size. If INP has degraded, cap the list.
- Check scroll restoration on back navigation from a PDP. Fix it if it's broken — it's worth more than most of this module.
Checklist¶
- Re‑render cost fixed before reaching for virtualization
-
content-visibilityon below‑fold list items withcontain-intrinsic-size - DOM nodes per card minimized
- Correct
sizeson all grid images; only the first row ispriority - Infinite scroll capped, URL‑synced, with a paginated fallback
- Reserved space for loading indicators
- Scroll restoration works on back navigation
- Virtualized lists keep
aria-setsize/aria-posinset - Long‑session memory and INP tested, not just first load
Next: 5.5 Concurrent React