DEV Community

Ishaan Pandey
Ishaan Pandey

Posted on • Originally published at ishaaan.hashnode.dev

Rendering a Million Rows: The Ultimate Frontend Performance Guide — Virtualization, Streaming, SSR & Beyond

Rendering a Million Rows: The Ultimate Frontend Performance Guide — Virtualization, Streaming, SSR & Beyond

You've got a million rows. Your product manager wants them all accessible in the UI. The designer mocked up a beautiful infinite-scrolling table. And your browser just burst into flames.

Sound familiar?

Rendering massive datasets on the frontend is one of those problems that seems simple until you actually try it. Then you discover that the DOM is not your friend when you're asking it to manage a million nodes.

This guide covers every major technique for handling large datasets on the frontend — from the classics (pagination) to the advanced (canvas rendering, web workers, streaming SSR). We'll look at real code, real tradeoffs, and how companies like Google, Figma, and Bloomberg actually solve this problem.

Let's get into it.


The Problem: Why Naive Rendering Kills the Browser

Let's start with the obvious approach — just render everything:

function NaiveTable({ rows }) {
  return (
    <table>
      <tbody>
        {rows.map((row, i) => (
          <tr key={i}>
            <td>{row.name}</td>
            <td>{row.email}</td>
            <td>{row.amount}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
Enter fullscreen mode Exit fullscreen mode

With 1,000 rows? Fine. With 10,000? Sluggish. With 100,000+? The tab crashes.

Here's why:

The DOM Tax

Every DOM node costs memory. A single <tr> with three <td> children is ~4 nodes. A million rows = ~4 million DOM nodes. Each node carries weight:

┌─────────────────────────────────────────────┐
│           What Each DOM Node Costs           │
├─────────────────────────────────────────────┤
│  Memory allocation    ~0.5-1 KB per node     │
│  Style calculation    O(n) for each node     │
│  Layout/reflow        Recalculates geometry  │
│  Paint                Renders pixels         │
│  Composite            GPU layer management   │
└─────────────────────────────────────────────┘

1M rows × 4 nodes × ~0.7 KB ≈ 2.8 GB of DOM memory
Enter fullscreen mode Exit fullscreen mode

The Rendering Pipeline

Every time the browser renders a frame, it goes through this pipeline:

JavaScript → Style → Layout → Paint → Composite
    ↑                                      │
    └──────── 16.67ms budget (60fps) ──────┘
Enter fullscreen mode Exit fullscreen mode

With a million DOM nodes, the "Layout" step alone can take seconds. The browser has to calculate the position and size of every single element. Scrolling becomes a slideshow.

The Real Bottlenecks

Bottleneck Impact Threshold
DOM node count Memory + layout time >10K nodes gets noticeable
Reflow/repaint Jank during scroll Any layout change triggers it
JavaScript execution Blocks main thread >50ms tasks cause jank
Memory pressure Tab crash / OOM Browser-dependent, ~1-4 GB
Garbage collection Random pauses More objects = more GC

The takeaway: you simply cannot put a million DOM nodes on the page. So what do you do?


Technique 1: Pagination — The Classic

The oldest trick in the book: don't show everything at once.

Offset-Based Pagination

// API call
const fetchPage = async (page, pageSize = 50) => {
  const res = await fetch(`/api/users?page=${page}&limit=${pageSize}`);
  return res.json();
};

function PaginatedTable() {
  const [page, setPage] = useState(1);
  const { data, isLoading } = useQuery(
    ['users', page],
    () => fetchPage(page)
  );

  return (
    <>
      <table>
        <tbody>
          {data?.rows.map(row => (
            <tr key={row.id}>
              <td>{row.name}</td>
              <td>{row.email}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <div className="pagination">
        <button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
          Previous
        </button>
        <span>Page {page} of {data?.totalPages}</span>
        <button onClick={() => setPage(p => p + 1)} disabled={page === data?.totalPages}>
          Next
        </button>
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Cursor-Based Pagination

Offset pagination breaks down at scale (the database still has to skip N rows). Cursor-based pagination is more efficient:

const fetchPage = async (cursor) => {
  const url = cursor
    ? `/api/users?cursor=${cursor}&limit=50`
    : `/api/users?limit=50`;
  const res = await fetch(url);
  return res.json();
  // Returns: { rows: [...], nextCursor: "abc123" }
};
Enter fullscreen mode Exit fullscreen mode

Pagination Pros & Cons

Aspect Offset Cursor
Jump to page N Yes No
Consistent with inserts No (rows shift) Yes
Database performance O(offset + limit) O(limit)
Implementation Simple Moderate
Best for Small datasets, admin panels Large datasets, feeds

Pagination is great when users don't need to see everything at once. But sometimes they do — enter virtualization.


Technique 2: Virtualization / Windowing

This is the big one. Virtualization is the single most impactful technique for rendering large lists.

How It Works

The idea is beautifully simple: only render the rows that are visible in the viewport.

┌──────────────────────────────────────┐
│          Virtualized List            │
│                                      │
│  ┌──────────────────────────────┐    │
│  │   Spacer (top padding)      │    │  ← Empty div with calculated height
│  │   height: 45000px            │    │     (represents rows above viewport)
│  ├──────────────────────────────┤    │
│  │   Row 901                    │    │  ┐
│  │   Row 902                    │    │  │
│  │   Row 903                    │    │  │  Only these ~20 rows
│  │   Row 904                    │    │  │  exist in the DOM
│  │   ...                        │    │  │
│  │   Row 920                    │    │  ┘
│  ├──────────────────────────────┤    │
│  │   Spacer (bottom padding)   │    │  ← Empty div with calculated height
│  │   height: 54000px            │    │     (represents rows below viewport)
│  └──────────────────────────────┘    │
│                                      │
│  Scrollbar reflects total height     │
└──────────────────────────────────────┘

Total rows: 100,000
DOM nodes: ~20 rows × 4 nodes = ~80 nodes
Enter fullscreen mode Exit fullscreen mode

The scroll container has a total height that matches what the full list would be. But only the visible slice (plus a small overscan buffer) is actually rendered. As the user scrolls, rows are recycled — old ones are destroyed, new ones are created.

react-window (Lightweight)

The go-to library for simple virtualized lists:

import { FixedSizeList as List } from 'react-window';

function VirtualizedList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style} className="row">
      <span>{items[index].name}</span>
      <span>{items[index].email}</span>
      <span>{items[index].amount}</span>
    </div>
  );

  return (
    <List
      height={600}           // viewport height
      itemCount={items.length} // total items
      itemSize={35}           // row height in px
      width="100%"
    >
      {Row}
    </List>
  );
}
Enter fullscreen mode Exit fullscreen mode

For variable-height rows, use VariableSizeList:

import { VariableSizeList as List } from 'react-window';

function VariableList({ items }) {
  const getItemSize = (index) => {
    // Could be dynamic based on content
    return items[index].expanded ? 120 : 35;
  };

  const Row = ({ index, style }) => (
    <div style={style}>{items[index].name}</div>
  );

  return (
    <List
      height={600}
      itemCount={items.length}
      itemSize={getItemSize}
      width="100%"
    >
      {Row}
    </List>
  );
}
Enter fullscreen mode Exit fullscreen mode

react-virtuoso (Batteries Included)

react-virtuoso handles variable heights automatically — no need to pre-calculate:

import { Virtuoso } from 'react-virtuoso';

function VirtuosoTable({ items }) {
  return (
    <Virtuoso
      style={{ height: '600px' }}
      totalCount={items.length}
      itemContent={(index) => (
        <div className="row">
          <span>{items[index].name}</span>
          <span>{items[index].email}</span>
        </div>
      )}
      // Grouped headers, sticky headers, etc. built in
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

It also supports grouped lists, table elements, reverse scrolling (chat UIs), and SSR out of the box.

TanStack Virtual (Framework Agnostic)

TanStack Virtual gives you the primitives — you build the UI:

import { useVirtualizer } from '@tanstack/react-virtual';

function TanStackList({ items }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 35,      // estimated row height
    overscan: 10,                 // extra rows above/below viewport
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            {items[virtualRow.index].name}
          </div>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Virtualization Library Comparison

Feature react-window react-virtuoso TanStack Virtual
Bundle size ~6 KB ~16 KB ~10 KB
Variable heights Manual Automatic Estimated + measured
Table support Limited Built-in Manual
Grouped/sticky headers No Yes Manual
SSR support Limited Yes Yes
Framework agnostic No (React) No (React) Yes
Horizontal lists Yes Limited Yes
Grid (2D) Yes No Yes
Learning curve Low Low Medium

When Virtualization Isn't Enough

Virtualization handles 100K–1M rows smoothly. But it has limits:

  • Scrollbar precision: At 1M rows × 35px = 35M px total height. Browsers cap scrollable height (Chrome: ~33.5M px). You may need to fake the scrollbar.
  • Search/filter: You still need all the data in memory for client-side operations.
  • Accessibility: Screen readers can't see off-screen rows. You need ARIA attributes to announce total count.

Technique 3: Infinite Scrolling with Intersection Observer

Infinite scroll loads more data as the user approaches the bottom. Combined with virtualization, it's powerful.

function InfiniteList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  const loaderRef = useRef(null);

  // Fetch next page
  const loadMore = useCallback(async () => {
    const res = await fetch(`/api/items?page=${page}&limit=100`);
    const data = await res.json();
    setItems(prev => [...prev, ...data.rows]);
    setHasMore(data.hasMore);
    setPage(prev => prev + 1);
  }, [page]);

  // Intersection Observer to trigger loading
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore) {
          loadMore();
        }
      },
      { threshold: 0.1 }
    );

    if (loaderRef.current) observer.observe(loaderRef.current);

    return () => observer.disconnect();
  }, [loadMore, hasMore]);

  return (
    <div style={{ height: '600px', overflow: 'auto' }}>
      {items.map(item => (
        <div key={item.id} className="row">{item.name}</div>
      ))}
      {hasMore && (
        <div ref={loaderRef} className="loader">
          Loading more...
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Virtualized Infinite Scroll (The Combo)

Pair TanStack Virtual with infinite loading for the best of both worlds:

import { useVirtualizer } from '@tanstack/react-virtual';
import { useInfiniteQuery } from '@tanstack/react-query';

function VirtualizedInfiniteList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ['items'],
      queryFn: ({ pageParam = 0 }) =>
        fetch(`/api/items?cursor=${pageParam}&limit=100`).then(r => r.json()),
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    });

  const allRows = data?.pages.flatMap(p => p.rows) ?? [];
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: hasNextPage ? allRows.length + 1 : allRows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 35,
    overscan: 5,
  });

  // Fetch more when approaching the end
  useEffect(() => {
    const lastItem = virtualizer.getVirtualItems().at(-1);
    if (!lastItem) return;

    if (
      lastItem.index >= allRows.length - 1 &&
      hasNextPage &&
      !isFetchingNextPage
    ) {
      fetchNextPage();
    }
  }, [virtualizer.getVirtualItems(), hasNextPage, isFetchingNextPage, allRows.length, fetchNextPage]);

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((virtualRow) => {
          const isLoader = virtualRow.index >= allRows.length;
          return (
            <div
              key={virtualRow.key}
              style={{
                position: 'absolute',
                top: `${virtualRow.start}px`,
                width: '100%',
                height: `${virtualRow.size}px`,
              }}
            >
              {isLoader ? 'Loading...' : allRows[virtualRow.index].name}
            </div>
          );
        })}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Technique 4: SSR & Streaming with React Server Components

Server-side rendering gets the initial content to the user faster. React 18+ streaming makes it even better.

Streaming HTML with Suspense Boundaries

Instead of waiting for all data to load before sending HTML, you can stream it:

// app/dashboard/page.tsx (Next.js App Router)
import { Suspense } from 'react';

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* This renders immediately */}
      <header>Welcome back, user</header>

      {/* This streams in when the data is ready */}
      <Suspense fallback={<TableSkeleton />}>
        <DataTable />
      </Suspense>

      {/* This also renders immediately */}
      <footer>Footer content</footer>
    </div>
  );
}

// Server Component — runs on the server, streams to client
async function DataTable() {
  const data = await db.query('SELECT * FROM transactions LIMIT 1000');

  return (
    <table>
      <tbody>
        {data.map(row => (
          <tr key={row.id}>
            <td>{row.date}</td>
            <td>{row.description}</td>
            <td>${row.amount}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
Enter fullscreen mode Exit fullscreen mode

How Streaming Works Under the Hood

Traditional SSR:
  Server: [──── Build full HTML ────] → Send complete HTML → Client renders
  Client: [────── waiting ──────────]   [── hydrate ──]

Streaming SSR:
  Server: [─ Shell ─] → Send shell   [─ Data table ─] → Stream chunk
  Client: [─ render ─] [─ skeleton ─] [── swap in ──]

Timeline:
  0ms     200ms       500ms         1200ms
  │        │           │              │
  │  Shell  │  Skeleton │  Real table  │
  │  sent   │  visible  │  streamed in │
Enter fullscreen mode Exit fullscreen mode

The user sees the page layout and skeleton immediately. The heavy data table streams in when it's ready.

Progressive Loading Pattern

For truly massive datasets, combine SSR with client-side virtualization:

// Server Component: sends the first page
async function InitialData() {
  const firstPage = await db.query(
    'SELECT * FROM items ORDER BY id LIMIT 100'
  );

  return <VirtualizedTable initialData={firstPage} />;
}

// Client Component: virtualizes and loads more
'use client';
function VirtualizedTable({ initialData }) {
  const [items, setItems] = useState(initialData);

  // Load more pages on scroll with virtualization
  // ... (combine with infinite scroll pattern above)
}
Enter fullscreen mode Exit fullscreen mode

Technique 5: Canvas-Based Rendering

When even virtualized DOM is too slow — think spreadsheet-like UIs with millions of cells — you can skip the DOM entirely and paint pixels on a <canvas>.

Why Canvas?

DOM rendering:
  Each cell = DOM node → style calc → layout → paint → composite
  10,000 cells = 10,000 nodes through the pipeline

Canvas rendering:
  All cells = one <canvas> element → single paint operation
  10,000 cells = 10,000 fillRect() calls on one surface
Enter fullscreen mode Exit fullscreen mode

How Spreadsheet Engines Work

┌────────────────────────────────────────────────────┐
│                    Canvas Layer                      │
│  ┌──────┬──────┬──────┬──────┬──────┐              │
│  │  A1  │  B1  │  C1  │  D1  │  E1  │  ← Painted  │
│  ├──────┼──────┼──────┼──────┼──────┤    pixels,   │
│  │  A2  │  B2  │  C2  │  D2  │  E2  │    not DOM   │
│  ├──────┼──────┼──────┼──────┼──────┤    nodes     │
│  │  A3  │  B3  │  C3  │  D3  │  E3  │              │
│  └──────┴──────┴──────┴──────┴──────┘              │
├────────────────────────────────────────────────────┤
│  Hidden DOM layer (for accessibility):              │
│  <div role="grid" aria-rowcount="1000000">          │
│    <div role="row" aria-rowindex="1">               │
│      <div role="gridcell">A1 content</div>          │
│    </div>                                           │
│  </div>                                             │
├────────────────────────────────────────────────────┤
│  Input overlay (for editing):                       │
│  <input> positioned absolutely over active cell     │
└────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Basic Canvas Grid Renderer

class CanvasGrid {
  constructor(canvas, data, config) {
    this.ctx = canvas.getContext('2d');
    this.data = data;             // 2D array of cell values
    this.cellWidth = config.cellWidth || 120;
    this.cellHeight = config.cellHeight || 30;
    this.scrollX = 0;
    this.scrollY = 0;
    this.visibleRows = Math.ceil(canvas.height / this.cellHeight) + 1;
    this.visibleCols = Math.ceil(canvas.width / this.cellWidth) + 1;

    canvas.addEventListener('wheel', (e) => this.onScroll(e));
    this.render();
  }

  onScroll(e) {
    this.scrollX += e.deltaX;
    this.scrollY += e.deltaY;
    this.scrollX = Math.max(0, this.scrollX);
    this.scrollY = Math.max(0, this.scrollY);
    requestAnimationFrame(() => this.render());
  }

  render() {
    const { ctx, data, cellWidth, cellHeight } = this;
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

    const startRow = Math.floor(this.scrollY / cellHeight);
    const startCol = Math.floor(this.scrollX / cellWidth);
    const offsetY = -(this.scrollY % cellHeight);
    const offsetX = -(this.scrollX % cellWidth);

    for (let r = 0; r < this.visibleRows; r++) {
      for (let c = 0; c < this.visibleCols; c++) {
        const dataRow = startRow + r;
        const dataCol = startCol + c;
        if (dataRow >= data.length || dataCol >= data[0].length) continue;

        const x = offsetX + c * cellWidth;
        const y = offsetY + r * cellHeight;

        // Draw cell border
        ctx.strokeStyle = '#e0e0e0';
        ctx.strokeRect(x, y, cellWidth, cellHeight);

        // Draw cell text
        ctx.fillStyle = '#333';
        ctx.font = '13px Inter, sans-serif';
        ctx.fillText(
          data[dataRow][dataCol],
          x + 8,
          y + cellHeight / 2 + 4,
        );
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Libraries That Use Canvas Rendering

Library Approach Best For
ag-Grid (Enterprise) Canvas for cells, DOM for chrome Data grids with millions of rows
Handsontable Canvas hybrid Spreadsheet UIs
Glide Data Grid Full canvas React-based spreadsheets
PixiJS + custom WebGL/Canvas Custom visualizations
Luckysheet Canvas Google Sheets clone

Technique 6: Web Workers for Off-Main-Thread Computation

Sorting, filtering, or aggregating a million rows on the main thread blocks the UI. Move it to a Web Worker.

// worker.js
self.onmessage = function(e) {
  const { type, data, params } = e.data;

  switch (type) {
    case 'sort': {
      const sorted = [...data].sort((a, b) => {
        const valA = a[params.column];
        const valB = b[params.column];
        return params.direction === 'asc'
          ? valA > valB ? 1 : -1
          : valA < valB ? 1 : -1;
      });
      self.postMessage({ type: 'sorted', data: sorted });
      break;
    }

    case 'filter': {
      const filtered = data.filter(row =>
        row[params.column]
          .toLowerCase()
          .includes(params.query.toLowerCase())
      );
      self.postMessage({ type: 'filtered', data: filtered });
      break;
    }

    case 'aggregate': {
      const sum = data.reduce((acc, row) => acc + row[params.column], 0);
      const avg = sum / data.length;
      self.postMessage({
        type: 'aggregated',
        data: { sum, avg, count: data.length },
      });
      break;
    }
  }
};
Enter fullscreen mode Exit fullscreen mode
// useWorkerSort.js — Custom hook
function useWorkerSort(data) {
  const [sorted, setSorted] = useState(data);
  const [sorting, setSorting] = useState(false);
  const workerRef = useRef(null);

  useEffect(() => {
    workerRef.current = new Worker(
      new URL('./worker.js', import.meta.url)
    );

    workerRef.current.onmessage = (e) => {
      if (e.data.type === 'sorted') {
        setSorted(e.data.data);
        setSorting(false);
      }
    };

    return () => workerRef.current.terminate();
  }, []);

  const sort = useCallback((column, direction) => {
    setSorting(true);
    workerRef.current.postMessage({
      type: 'sort',
      data,
      params: { column, direction },
    });
  }, [data]);

  return { sorted, sorting, sort };
}
Enter fullscreen mode Exit fullscreen mode

Transferable Objects for Zero-Copy

For really large datasets, use Transferable objects to avoid copying data between threads:

// Instead of copying the array:
worker.postMessage({ data: hugeArray });

// Transfer ownership (zero-copy, but main thread loses access):
const buffer = new Float64Array(1_000_000);
worker.postMessage({ buffer }, [buffer.buffer]);
// buffer is now empty on the main thread — ownership transferred
Enter fullscreen mode Exit fullscreen mode

Technique 7: requestAnimationFrame & Time-Slicing

If you must render many DOM nodes (maybe you're building a static report), do it in chunks so the browser stays responsive:

function renderInChunks(items, container, chunkSize = 500) {
  let index = 0;

  function renderChunk() {
    const fragment = document.createDocumentFragment();
    const end = Math.min(index + chunkSize, items.length);

    for (let i = index; i < end; i++) {
      const row = document.createElement('div');
      row.className = 'row';
      row.textContent = items[i].name;
      fragment.appendChild(row);
    }

    container.appendChild(fragment);
    index = end;

    if (index < items.length) {
      // Yield to the browser — let it paint, handle events, etc.
      requestAnimationFrame(renderChunk);
    }
  }

  requestAnimationFrame(renderChunk);
}
Enter fullscreen mode Exit fullscreen mode

React 18's Built-in Time-Slicing with useTransition

React 18 gives you this for free:

function SearchableList({ items }) {
  const [query, setQuery] = useState('');
  const [filtered, setFiltered] = useState(items);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (e) => {
    const value = e.target.value;
    setQuery(value); // Urgent update — input stays responsive

    startTransition(() => {
      // Low-priority — React can interrupt this to keep UI responsive
      const result = items.filter(item =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFiltered(result);
    });
  };

  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending && <span>Filtering...</span>}
      <VirtualizedList items={filtered} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

React will break up the state update into smaller chunks and yield back to the browser between them. The input never feels sluggish.


Technique 8: CSS Containment & content-visibility

These CSS properties tell the browser it can skip work for off-screen elements.

content-visibility: auto

This is the CSS equivalent of virtualization — the browser skips rendering for off-screen elements:

.row {
  content-visibility: auto;
  contain-intrinsic-size: 0 50px; /* estimated height when hidden */
}
Enter fullscreen mode Exit fullscreen mode
Without content-visibility:
  Browser renders ALL 100,000 rows
  ┌─────────────────────┐
  │ Row 1    [rendered]  │  viewport
  │ Row 2    [rendered]  │
  │ Row 3    [rendered]  │
  ├─────────────────────┤
  │ Row 4    [rendered]  │  off-screen but still
  │ Row 5    [rendered]  │  fully rendered
  │ ...                  │
  │ Row 100K [rendered]  │
  └─────────────────────┘

With content-visibility: auto:
  Browser only renders visible rows
  ┌─────────────────────┐
  │ Row 1    [rendered]  │  viewport
  │ Row 2    [rendered]  │
  │ Row 3    [rendered]  │
  ├─────────────────────┤
  │ Row 4    [skipped]   │  layout calculated,
  │ Row 5    [skipped]   │  but paint/style skipped
  │ ...                  │
  │ Row 100K [skipped]   │
  └─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

CSS contain Property

More granular control over what the browser can optimize:

.data-table-row {
  contain: layout style paint;
  /* layout: this element's layout doesn't affect siblings */
  /* style: counters/etc don't leak out */
  /* paint: children don't paint outside bounds */
}

/* Nuclear option — all containment */
.isolated-widget {
  contain: strict;
}
Enter fullscreen mode Exit fullscreen mode

When to Use CSS vs. JavaScript Virtualization

Approach DOM Nodes Memory Scroll Perf Setup
content-visibility All in DOM High Good 2 lines of CSS
JS Virtualization Only visible Low Excellent Library setup

Use content-visibility for moderate lists (1K–10K items). Use JS virtualization for anything larger.


Technique 9: Lazy Loading Strategies

Progressive Disclosure

Show summary first, load details on demand:

function ExpandableRow({ row }) {
  const [expanded, setExpanded] = useState(false);
  const [details, setDetails] = useState(null);

  const handleExpand = async () => {
    if (!expanded && !details) {
      const res = await fetch(`/api/rows/${row.id}/details`);
      setDetails(await res.json());
    }
    setExpanded(!expanded);
  };

  return (
    <>
      <tr onClick={handleExpand}>
        <td>{row.name}</td>
        <td>{row.summary}</td>
        <td>{expanded ? '' : ''}</td>
      </tr>
      {expanded && details && (
        <tr>
          <td colSpan={3}>
            <DetailView data={details} />
          </td>
        </tr>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Skeleton Screens

Show structure immediately, fill in data when ready:

function TableSkeleton({ rows = 20, cols = 5 }) {
  return (
    <table>
      <tbody>
        {Array.from({ length: rows }).map((_, i) => (
          <tr key={i}>
            {Array.from({ length: cols }).map((_, j) => (
              <td key={j}>
                <div
                  className="skeleton-pulse"
                  style={{
                    width: `${60 + Math.random() * 40}%`,
                    height: '16px',
                    borderRadius: '4px',
                    background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
                    backgroundSize: '200% 100%',
                    animation: 'shimmer 1.5s infinite',
                  }}
                />
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}
Enter fullscreen mode Exit fullscreen mode

Table Libraries Compared

If you don't want to build from scratch, here's how the major table libraries stack up:

Feature TanStack Table ag-Grid MUI DataGrid
Bundle size ~15 KB ~200 KB (community) ~90 KB
Virtualization Via TanStack Virtual Built-in (excellent) Built-in
Max rows tested 100K+ 1M+ (canvas mode) 100K+
Sorting Built-in Built-in + server Built-in + server
Filtering Built-in Built-in (advanced) Built-in
Grouping/Pivot Plugin Enterprise feature Pro feature
Column resize Plugin Built-in Built-in
Editing Manual Built-in Built-in
Export (CSV/Excel) Manual Enterprise Pro
Rendering DOM DOM + Canvas hybrid DOM
Framework Any Any (React/Vue/Angular) React (MUI)
License MIT Community: MIT / Enterprise: paid Community: MIT / Pro: paid
Headless? Yes (UI agnostic) No (provides UI) No (provides UI)

Quick Recommendation

  • Headless + full control: TanStack Table + TanStack Virtual
  • Enterprise data grid: ag-Grid Enterprise (nothing else comes close at 1M+ rows)
  • MUI ecosystem: MUI DataGrid Pro
  • Simple table with sort/filter: TanStack Table (lightweight, flexible)

Real-World Examples

How Google Sheets Handles Millions of Cells

Google Sheets renders a canvas-based grid for the cell area. The DOM is used only for the toolbar, menus, and accessibility layer. Key techniques:

  • Canvas rendering for cells — one <canvas> element for the entire sheet
  • Tile-based rendering — the sheet is divided into tiles, only visible tiles are rendered
  • Web Workers for formula calculation — complex formulas don't block scrolling
  • Incremental loading — only the visible range is fetched from the server

How Figma Renders Complex UIs

Figma uses a WebGL canvas for the design surface. The entire editor is rendered via GPU:

  • WebGL rendering — GPU-accelerated, not DOM-based
  • Custom text layout engine — doesn't use the browser's text rendering for the canvas
  • WASM for performance-critical code paths
  • Off-screen rendering — invisible objects are culled from the render tree

How Bloomberg Terminal Handles Real-Time Data

Bloomberg Terminal (web version) processes thousands of real-time updates per second:

  • WebSocket streams for live price feeds
  • Double-buffering — updates are batched and swapped in on the next frame
  • DOM recycling — rows are reused, only cell content changes
  • requestAnimationFrame batching — coalesces rapid updates into single frames

Performance Metrics That Matter

When optimizing large list rendering, track these metrics:

Metric What It Measures Target Tool
FCP (First Contentful Paint) When first content appears < 1.8s Lighthouse
LCP (Largest Contentful Paint) When main content is visible < 2.5s Lighthouse
TTI (Time to Interactive) When page is fully interactive < 3.8s Lighthouse
TBT (Total Blocking Time) Main thread blocked time < 200ms Lighthouse
CLS (Cumulative Layout Shift) Visual stability < 0.1 Lighthouse
INP (Interaction to Next Paint) Input responsiveness < 200ms Chrome UX Report
FPS during scroll Scroll smoothness 60 fps Chrome DevTools
JS Heap Size Memory consumption < 200 MB DevTools Memory tab
DOM Node Count DOM complexity < 1,500 Lighthouse

Profiling Scroll Performance

// Quick FPS counter for development
let lastTime = performance.now();
let frames = 0;

function measureFPS() {
  frames++;
  const now = performance.now();
  if (now - lastTime >= 1000) {
    console.log(`FPS: ${frames}`);
    frames = 0;
    lastTime = now;
  }
  requestAnimationFrame(measureFPS);
}

requestAnimationFrame(measureFPS);
Enter fullscreen mode Exit fullscreen mode

Decision Framework: Which Technique for Which Scale

How many rows do you need to display?
│
├─ < 100 rows
│   └─ Just render them. No optimization needed.
│
├─ 100 – 1,000 rows
│   └─ Pagination OR content-visibility: auto
│       Simple, effective, almost zero overhead.
│
├─ 1,000 – 10,000 rows
│   └─ Virtualization (react-window / react-virtuoso)
│       + Pagination or infinite scroll for data fetching
│
├─ 10,000 – 100,000 rows
│   └─ Virtualization + Web Workers for sort/filter
│       + Server-side pagination (cursor-based)
│       + Streaming SSR for initial load
│
├─ 100,000 – 1,000,000 rows
│   └─ Virtualization + Web Workers + Canvas hybrid
│       + Server-streaming (NDJSON / gRPC)
│       + Consider ag-Grid Enterprise
│
└─ > 1,000,000 rows
    └─ Full canvas rendering (like Google Sheets)
        + Server-side everything (sort, filter, aggregate)
        + WebSocket for real-time updates
        + Possibly WASM for client-side computation
Enter fullscreen mode Exit fullscreen mode

Quick Reference Table

Scale Technique Complexity Time to Implement
< 1K Raw rendering + CSS Low Minutes
1K–10K Virtualization library Low-Med Hours
10K–100K Virtual + workers + streaming Medium Days
100K–1M Canvas hybrid + server-side ops High Weeks
> 1M Full canvas + WASM + custom infra Very High Months

Wrapping Up

Rendering massive datasets is fundamentally about one thing: doing less work. Every technique we covered is a way to avoid unnecessary computation:

  • Pagination — don't load what the user hasn't asked for
  • Virtualization — don't render what the user can't see
  • Canvas — don't create DOM nodes for things that are just pixels
  • Web Workers — don't block the UI thread with computation
  • Streaming — don't wait for everything before showing something
  • CSS containment — don't paint what's off-screen

Start with the simplest technique that meets your requirements. Pagination solves 80% of use cases. Virtualization covers another 15%. Canvas rendering and WASM are for the remaining 5% — the Google Sheets and Figma-scale problems.

The best optimization is the one you don't need. But when you do need it, now you know every option on the table.


Let's Connect!

If you found this guide helpful, I'd love to connect with you! I regularly share deep dives on system design, backend engineering, and software architecture.

Connect with me on LinkedIn — let's grow together.

Drop a comment, share this with someone who needs this, and follow along for more guides like this!

Top comments (0)