Skip to content

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:

  1. React render time — 48 cards × 7.6 ms = 365 ms per re‑render
  2. DOM node count — 48 × 25 nodes = 1,200 nodes; style recalc scales with this
  3. Layout cost — the browser lays out every node
  4. Memory — images, event listeners, and fiber nodes
  5. 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:

.product-tile {
  content-visibility: auto;
  contain-intrinsic-size: auto 420px;
}

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:

  1. 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.
  2. Keep the URL in sync (history.replaceState) so refresh and share work.
  3. Provide a real paginated fallback for crawlers and keyboard users.
  4. Reserve space for the loading row or you get CLS on every load.
  5. 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

  1. Count. How many items render on your PLP by default? If under 100, virtualization is probably not your fix.
  2. Profile a filter change in the React Profiler. Note the commit time and the number of cards rendered.
  3. Apply the cheap fixes in order: stable props + memo, content-visibility, fewer DOM nodes per card, URL filters + startTransition. Measure after each.
  4. Only then, if you still have 500+ items in one container, virtualize — and test SEO, Ctrl+F, scroll restoration, and screen‑reader announcement afterwards.
  5. Test a long session: load 10 pages of infinite scroll, then measure INP and heap size. If INP has degraded, cap the list.
  6. 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-visibility on below‑fold list items with contain-intrinsic-size
  • DOM nodes per card minimized
  • Correct sizes on all grid images; only the first row is priority
  • 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