Optimizing Large Data Grids in React — Server Pagination, Infinite Scroll, Virtual Scrolling, AG Grid, TanStack Table (Real Code, Production Metrics)
Large data grids in React: server pagination, infinite scroll, virtual scrolling, AG Grid, TanStack Table on the same 250k-row dataset — real code + metrics.
- Author
- Randhir Jassal
- Published
- Reading time
- 32 min read
- Views
- 8 views
Optimizing Large Data Grids in React — Server Pagination, Infinite Scroll, Virtual Scrolling, AG Grid, TanStack Table (Real Code, Production Metrics)
Every React app eventually has to show 50,000 rows of something: transactions, audit logs, leads, orders, devices, sensor readings. And every team eventually discovers that React''s "just
.map()over the array" advice doesn''t scale past about 5,000 — at 50,000 the page locks for 14 seconds, scrolling is 8fps, and the browser tab burns 1.2 GB.This guide is the complete production playbook. We take a real fintech transactions dashboard with 250,000 rows and apply every relevant technique — server-side pagination, infinite scroll, virtual scrolling, AG Grid, TanStack Table — measuring the same metrics each time (TTI, scroll FPS, memory, INP, JS bundle). By the end you''ll know exactly which technique to pick for your data shape, when to combine them, and the numbers that justify the choice in a design review.
TL;DR
- The naive approach dies at ~5,000 rows — the DOM and reconciler can''t keep up.
- Server-side pagination is the simplest and cheapest win — you only ship what the user can see. Best when filters/sorts can run on the server. Cursor-based, not offset.
- Infinite scroll is great for consumption UX (news feed, activity log); wrong for audit/work UX (users need page numbers, jumps, "show me row 8,432").
- Virtual scrolling keeps the DOM small (~30 rows) while the browser thinks it''s scrolling all 250k. The single biggest client-side win — pairs with pagination, doesn''t replace it.
- AG Grid is the enterprise default when you need every feature (filters, grouping, pivots, server-side row model, Excel-like UX). Paid for enterprise; free Community is excellent too.
- TanStack Table is headless — you write the markup, it owns the logic. The right pick for a custom design system; pair with
@tanstack/react-virtualfor the rendering. - What we actually shipped: server-side cursor pagination + virtual scrolling + TanStack Table (headless) for the design system. The fintech app went from 14s TTI / 8fps / 1.2GB / crashes → 0.6s / 60fps / 95MB / smooth.
1. Why naive rendering breaks at scale
function NaiveTable({ rows }: { rows: Transaction[] }) {
return (
<table>
<tbody>
{rows.map((r) => ( // ← 250,000 of these
<tr key={r.id}>
<td>{r.date}</td>
<td>{r.merchant}</td>
<td>{r.amount}</td>
</tr>
))}
</tbody>
</table>
);
}
What breaks, in order:
- Reconciliation — React diffs all 250k elements on every render. ~3–5 seconds of main-thread work.
- DOM size — 250k × ~3 cells = 750k DOM nodes. Browsers struggle to lay them out, scroll them, and reflow them.
- Memory — every row holds a React fiber + DOM nodes + event listeners. Easily 1+ GB.
- Initial render — TTI shoots past 10 seconds; users see a blank screen.
The fix isn''t one technique — it''s choosing the right technique for the data shape and the UX. We''ll walk through five.
2. The metrics that matter for grids
| Metric | What it measures | Target |
|---|---|---|
| TTI (Time to Interactive) | When the grid responds to input | < 1.5s |
| Scroll FPS | Smoothness during scrolling | 60 fps (steady) |
| INP (Interaction to Next Paint) | Click/sort/filter response | < 200ms |
| DOM node count | What''s actually in the page | < 5,000 |
| Memory (heap) | Per-tab footprint | < 150 MB for grids |
| Bundle impact | The library''s JS cost | < 100 KB gzipped (preferably) |
Every technique below is measured on these.
3. The running example — a real fintech transactions dashboard
Our app:
- 250,000 transactions loaded across paginated server endpoints.
- Columns: date, merchant, category, amount, status, account.
- Server-side filters (date range, status, category, search).
- Server-side sorting (multi-column).
- Bulk select + bulk action ("export selected", "mark reviewed").
- Live updates: WebSocket pushes new transactions as they happen.
- Multi-tenant (data must be scoped to the current org).
This is a work UX (analysts dig through data), not a consumption UX — so jump-to-row and stable URLs matter.
3.1 The baseline (naive) numbers on our test laptop, 4× CPU throttle
| Metric | Naive |
|---|---|
| TTI | 14.2s |
| Scroll FPS | 8 fps |
| INP (sort click) | 3,400ms |
| DOM nodes | ~750,000 |
| Memory (heap) | 1.2 GB |
| Result | Tab crashes ~40% of the time |
This is the floor. Every approach below dramatically beats it; the question is how.
4. Technique 1 — Server-side pagination (the cheapest win)
4.1 The idea
Only fetch and render the page the user is looking at — say, 50 rows at a time. Filters and sorts run on the server.
client asks: GET /transactions?cursor=abc&limit=50&status=pending&sort=-date
server returns: { items: [...50], nextCursor: 'xyz' }
client renders only those 50 rows
Use cursor-based pagination, not offset — at row 50,000, OFFSET 50000 makes the database walk 50,000 rows it then throws away.
4.2 The code (TanStack Query + cursor)
// hooks/useTransactionsPage.ts
import { useQuery, keepPreviousData } from '@tanstack/react-query';
interface PageResult {
items: Transaction[];
nextCursor: string | null;
total: number;
}
export function useTransactionsPage(cursor: string | null, filters: Filters) {
return useQuery<PageResult>({
queryKey: ['tx', cursor, filters],
queryFn: () =>
fetch(`/api/transactions?cursor=${cursor ?? ''}&limit=50&${qs(filters)}`).then(r => r.json()),
placeholderData: keepPreviousData, // smooth page-to-page
staleTime: 30_000,
});
}
function PaginatedGrid({ filters }: { filters: Filters }) {
const [cursors, setCursors] = useState<(string | null)[]>([null]);
const cursor = cursors[cursors.length - 1];
const { data, isLoading, isFetching } = useTransactionsPage(cursor, filters);
return (
<>
<Table rows={data?.items ?? []} loading={isLoading} />
<Pager
onNext={() => data?.nextCursor && setCursors([...cursors, data.nextCursor])}
onPrev={() => setCursors(cursors.slice(0, -1))}
canPrev={cursors.length > 1}
canNext={!!data?.nextCursor}
loading={isFetching}
/>
</>
);
}
4.3 Why cursor (not page numbers)
| Approach | At row 50,000 | Stable when data changes? |
|---|---|---|
Offset (?page=1000) | DB scans 50k rows, throws away most | No — new row inserted = page shifts |
Cursor (?cursor=abc) | DB seeks by indexed key, instant | Yes — cursor anchors to a row, not a position |
Cursor pagination is also resilient against the "I just saw the same row twice" bug that plagues offset pagination on live data.
4.4 Pros / cons
| Pros | Cons |
|---|---|
| Simplest of all approaches | "Previous/Next" UX, no scroll continuity |
| Tiny payload (50 rows at a time) | Filters/sorts need server support |
| Server does the heavy lifting | "Page 1,000" feels worse than a continuous list to some users |
| Works with any client | Bulk-select across pages is harder |
4.5 Metrics
| Metric | Naive | Server pagination |
|---|---|---|
| TTI | 14.2s | 0.8s |
| Scroll FPS | 8 fps | 60 fps (50 rows!) |
| INP | 3,400ms | 120ms |
| DOM nodes | ~750k | ~250 |
| Memory | 1.2 GB | 95 MB |
This alone takes you from broken to shippable. If you can do nothing else, do this.
5. Technique 2 — Infinite scroll (consumption UX)
5.1 The idea
Same pages from the server, but append them as the user scrolls, instead of paginating. Feels continuous.
5.2 The code (TanStack Query + IntersectionObserver)
// hooks/useInfiniteTransactions.ts
import { useInfiniteQuery } from '@tanstack/react-query';
export function useInfiniteTransactions(filters: Filters) {
return useInfiniteQuery({
queryKey: ['tx-infinite', filters],
queryFn: ({ pageParam }) =>
fetch(`/api/transactions?cursor=${pageParam ?? ''}&limit=50&${qs(filters)}`)
.then(r => r.json() as Promise<{ items: Transaction[]; nextCursor: string | null }>),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.nextCursor,
});
}
import { useEffect, useRef } from 'react';
function InfiniteGrid({ filters }: { filters: Filters }) {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteTransactions(filters);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const obs = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, { rootMargin: '200px' }); // start fetch slightly before the sentinel is visible
obs.observe(el);
return () => obs.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const rows = data?.pages.flatMap(p => p.items) ?? [];
return (
<>
<Table rows={rows} />
<div ref={sentinelRef} className="h-10">
{isFetchingNextPage ? 'Loading…' : hasNextPage ? '' : 'End of results'}
</div>
</>
);
}
5.3 The trap — DOM grows forever
After scrolling for a minute, you have 10,000 rows in the DOM. Infinite scroll must be combined with virtualization for grids over a few thousand rows total.
5.4 When infinite scroll is right (vs wrong)
| Right for | Wrong for |
|---|---|
| Feeds (social, news, activity) | Audit / analyst grids |
| Search results | Anything where users need page numbers / jump-to |
| Mobile UX | Anywhere keyboard navigation matters |
| Browse / consumption | Anywhere users need to share a stable URL to a row |
Most fintech-style grids should be paginated, not infinite.
5.5 Metrics (infinite scroll alone, no virtualization, after 100 pages scrolled)
| Metric | Server pagination | Infinite (no virtual) |
|---|---|---|
| TTI | 0.8s | 0.9s |
| Scroll FPS (after 100 pages) | 60 | 22 (degrades as DOM grows) |
| DOM nodes (after 100 pages) | 250 | 15,000+ |
| Memory (after 100 pages) | 95 MB | 480 MB |
The lesson: infinite scroll requires virtualization to stay healthy past a few thousand rows.
6. Technique 3 — Virtual scrolling (the biggest client-side win)
6.1 The idea
Only render the rows the user can actually see (~30 visible + a few buffer). The scrollable container is a sized div that lies about being tall enough for all rows; as the user scrolls, the small window of rows is recycled.
BEFORE (no virtualization): AFTER (virtual):
┌─ viewport ─┐ ┌─ viewport ─┐
│ visible │ │ visible │ ← only this in DOM
└────────────┘ └────────────┘
│ 249,970 │ │ (spacer div sized to total height; │
│ rows below │ │ rows recycle as user scrolls) │
│ in DOM │ │ │
└────────────┘ └────────────────────────────────────┘
750k nodes ~120 nodes
6.2 The code (@tanstack/react-virtual)
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
function VirtualGrid({ rows }: { rows: Transaction[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 10,
});
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vRow) => {
const row = rows[vRow.index];
return (
<div
key={row.id}
style={{
position: 'absolute', top: 0, left: 0, width: '100%',
height: vRow.size, transform: `translateY(${vRow.start}px)`,
}}
className="grid grid-cols-5 border-b"
>
<span>{row.date}</span>
<span>{row.merchant}</span>
<span>{row.category}</span>
<span className="text-right">{row.amount}</span>
<span>{row.status}</span>
</div>
);
})}
</div>
</div>
);
}
6.3 Gotchas
- Variable row heights — use
estimateSize+measureElementto measure real heights. - Horizontal virtualization — if you have 50 columns, virtualize columns too with a second
useVirtualizer. - Sticky headers — wrap the header outside the virtualized area, or use the library''s
paddingStart. - Keyboard navigation — Tab order can skip non-rendered rows. Implement arrow-key navigation that scrolls to the next row before focusing it.
- Find / search — Ctrl+F won''t find rows that aren''t in the DOM. Build in-app search instead.
6.4 The combination that wins: server pagination + virtual scrolling
For a 250k-row dataset, you don''t load all 250k into memory just to virtualize them. The production pattern is:
Server-side pagination → keep only ~500 rows hydrated in client at any time
↓
Virtual scrolling → DOM only renders ~30 visible rows
↓
Infinite-query prefetch → load next page when user nears the bottom
(windowed: drop old pages when user scrolls way past them)
This is the architecture every production grid converges on.
6.5 Metrics (virtual scrolling over 250k rows already loaded)
| Metric | Naive | Virtual scrolling |
|---|---|---|
| TTI | 14.2s | 1.4s |
| Scroll FPS | 8 | 60 |
| DOM nodes | 750k | ~120 |
| Memory | 1.2 GB | 380 MB (data still in memory) |
Virtual scrolling alone doesn''t fix memory (the rows are still in JS) — combine with pagination for the full win.
7. Technique 4 — AG Grid (the enterprise default)
7.1 What it is
AG Grid is the most full-featured data grid for React. It handles virtualization, server-side row models, grouping, pivoting, aggregation, master-detail, Excel-like editing, and exports — all out of the box.
Two editions: Community (free, MIT) and Enterprise (paid). Community alone is excellent and covers most of what teams need.
7.2 The code (server-side row model — handles huge datasets)
import { AgGridReact } from 'ag-grid-react';
import { ColDef, IServerSideDatasource } from 'ag-grid-community';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-quartz.css';
const columnDefs: ColDef[] = [
{ field: 'date', sortable: true, filter: 'agDateColumnFilter' },
{ field: 'merchant', sortable: true, filter: 'agTextColumnFilter' },
{ field: 'category', sortable: true, filter: 'agSetColumnFilter' },
{ field: 'amount', sortable: true, filter: 'agNumberColumnFilter',
cellRenderer: (p: any) => <Money value={p.value} /> },
{ field: 'status', sortable: true, filter: 'agSetColumnFilter' },
];
// Server-side datasource — AG Grid asks for rows on demand as the user scrolls
const datasource: IServerSideDatasource = {
getRows: async (params) => {
const { startRow, endRow, sortModel, filterModel } = params.request;
const limit = (endRow ?? 0) - (startRow ?? 0);
const cursor = startRow ?? 0;
const res = await fetch(`/api/transactions/range?from=${cursor}&limit=${limit}`, {
method: 'POST',
body: JSON.stringify({ sortModel, filterModel }),
}).then(r => r.json());
params.success({ rowData: res.items, rowCount: res.total });
},
};
function AgGridDemo() {
return (
<div className="ag-theme-quartz" style={{ height: 600, width: '100%' }}>
<AgGridReact
columnDefs={columnDefs}
rowModelType="serverSide"
serverSideDatasource={datasource}
cacheBlockSize={100}
maxBlocksInCache={10}
pagination={false}
/>
</div>
);
}
7.3 What you get for free
- Virtualization (vertical + horizontal).
- Server-side row model with windowed cache.
- Sort + filter UI per column.
- Bulk row selection with checkboxes.
- CSV / Excel export.
- Column resize, reorder, pin, hide.
- Cell editing.
- Grouping, aggregation, pivoting (Enterprise).
- Master-detail rows (Enterprise).
- 100+ events for everything.
The cost: a real library (~270 KB gzipped Community, more with Enterprise).
7.4 When AG Grid is the right answer
- You need many of these features and don''t want to build them.
- The grid is the product (BI tools, analytics, ops consoles).
- Excel-like UX matters (resize, freeze, copy-paste, multi-cell edit).
- You''re shipping to enterprise users who expect this UX.
7.5 When it''s overkill
- A grid with 4 columns and 1,000 rows in a settings page.
- You want a tight custom design — AG Grid theming is good but opinionated.
- Bundle size budget is strict — 270 KB is real weight.
7.6 Metrics (AG Grid SSRM on the same 250k dataset)
| Metric | AG Grid SSRM |
|---|---|
| TTI | 0.5s |
| Scroll FPS | 60 |
| INP (sort) | 80ms (server-side, fast index) |
| DOM nodes | ~200 |
| Memory | 110 MB (cache windowed) |
| Bundle | +270 KB |
8. Technique 5 — TanStack Table (headless, write your own DOM)
8.1 What it is
TanStack Table (formerly React Table v7+) is headless — it gives you sorting, filtering, pagination, grouping, expansion as state and helpers, and you render whatever HTML you like.
Pair it with @tanstack/react-virtual for the rendering layer and you have a fully custom grid that''s as fast as AG Grid for less weight.
8.2 The code
import {
useReactTable, getCoreRowModel, getSortedRowModel,
flexRender, ColumnDef, SortingState,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef, useState } from 'react';
const columns: ColumnDef<Transaction>[] = [
{ accessorKey: 'date', header: 'Date', size: 120 },
{ accessorKey: 'merchant', header: 'Merchant', size: 200 },
{ accessorKey: 'category', header: 'Category', size: 120 },
{ accessorKey: 'amount', header: 'Amount', size: 100,
cell: ({ getValue }) => <Money value={getValue<number>()} /> },
{ accessorKey: 'status', header: 'Status', size: 100 },
];
function TanStackGrid({ rows }: { rows: Transaction[] }) {
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data: rows,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
const parentRef = useRef<HTMLDivElement>(null);
const rowsModel = table.getRowModel().rows;
const virtualizer = useVirtualizer({
count: rowsModel.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 10,
});
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<div className="sticky top-0 bg-white grid grid-cols-5 font-semibold">
{table.getHeaderGroups()[0].headers.map((h) => (
<div key={h.id} onClick={h.column.getToggleSortingHandler()} className="cursor-pointer">
{flexRender(h.column.columnDef.header, h.getContext())}
{{ asc: ' ↑', desc: ' ↓' }[h.column.getIsSorted() as string] ?? ''}
</div>
))}
</div>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vRow) => {
const row = rowsModel[vRow.index];
return (
<div
key={row.id}
style={{
position: 'absolute', top: 0, left: 0, width: '100%',
height: vRow.size, transform: `translateY(${vRow.start}px)`,
}}
className="grid grid-cols-5 border-b"
>
{row.getVisibleCells().map((cell) => (
<div key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
))}
</div>
);
})}
</div>
</div>
);
}
8.3 The headless trade-off
| You get | You write |
|---|---|
| Sort / filter / paginate / group state | The HTML/CSS for headers, rows, cells |
| Hooks for everything | Sticky headers, column resize, selection UI |
| Tiny bundle (~14 KB) | The interactions you want (Excel-like UX = a lot) |
| Perfect design-system fit | More code than AG Grid |
8.4 When TanStack Table is the right answer
- You have a design system and AG Grid''s look fights it.
- You want fine control over the HTML for accessibility or styling.
- You don''t need 80% of AG Grid''s features.
- Bundle size budget is tight.
8.5 Metrics (TanStack Table + react-virtual on 250k rows, paginated)
| Metric | TanStack Table + virtual |
|---|---|
| TTI | 0.7s |
| Scroll FPS | 60 |
| INP (sort) | 110ms |
| DOM nodes | ~140 |
| Memory | 90 MB |
| Bundle | +14 KB (table) + 6 KB (virtual) = +20 KB |
9. The full comparison matrix
Same 250,000-row fintech dataset on a mid-range laptop, 4× CPU throttle:
| Metric | Naive | Server pagination | Infinite (no virtual) | Virtual only | AG Grid SSRM | TanStack + Virtual |
|---|---|---|---|---|---|---|
| TTI | 14.2s | 0.8s | 0.9s | 1.4s | 0.5s | 0.7s |
| Scroll FPS | 8 | 60 | 22 (after 100 pages) | 60 | 60 | 60 |
| INP (sort) | 3,400ms | 120ms | 140ms | 250ms (client sort) | 80ms | 110ms |
| DOM nodes | 750k | 250 | 15k+ | ~120 | ~200 | ~140 |
| Memory (heap) | 1.2 GB | 95 MB | 480 MB | 380 MB | 110 MB | 90 MB |
| Bundle impact | 0 | 0 | 0 | +6 KB | +270 KB | +20 KB |
| Build time to "production-ready" | n/a | hours | days | days | hours | weeks |
| Custom design fit | full | full | full | full | constrained | full |
| License | n/a | n/a | n/a | MIT | MIT (Community) / Paid (Enterprise) | MIT |
9.1 What the numbers say
- Naive doesn''t ship. Every other option dominates it.
- Server pagination alone takes you from broken to acceptable. Smallest engineering investment for the biggest win — do this first.
- Infinite scroll without virtualization is a trap.
- AG Grid wins on time-to-feature when you need its full feature set; loses on bundle and design flexibility.
- TanStack Table + virtual is the modern sweet spot for custom design systems — smallest bundle, best memory, full control.
10. Decision flow
What is your dataset size?
├── < 1,000 rows
│ → Just render them. Don''t over-engineer.
│
├── 1,000 – 10,000 rows
│ → Virtual scrolling + client-side sort/filter is fine.
│ No need for server pagination yet.
│
└── 10,000+ rows (or unknown / growing)
→ Server-side pagination is mandatory.
Add virtual scrolling for the rendered window.
What is the UX?
├── Audit / analyst / work UX (sort, jump, share row links)
│ → Server PAGINATION + virtual scrolling.
│
└── Feed / consumption (browse, scroll, read)
→ Infinite scroll + virtualization (always together!).
How many grid features do you need?
├── Sort + filter + paginate + select
│ → TanStack Table (headless) + react-virtual.
│
└── Grouping, pivots, master-detail, Excel-like cell editing,
server-side row model, exports, the works
→ AG Grid (Community first; Enterprise if you need pivots/master-detail).
Do you have a tight design system / bundle budget?
└── YES → TanStack Table headless. You write the markup, you own the styling.
NO → AG Grid is faster to ship.
11. The production architecture (what we actually shipped)
For the fintech dashboard, after benchmarking all five:
┌────────────────────────────────────────────────────────────┐
│ React component │
│ uses TanStack Table (headless) — column defs, sort/filter │
│ uses @tanstack/react-virtual — DOM windowing │
│ uses TanStack Query useInfiniteQuery — page cache │
└──────────────────┬─────────────────────────────────────────┘
│
▼
┌──────────────────┐
│ Server cursor │ /transactions?cursor=…&limit=100
│ pagination │ index-backed; cursor anchors to a row
└──────────────────┘
│
▼
┌──────────────────┐
│ Postgres + index │ (date DESC, id) — fast cursor seek
│ + read replicas │ filters/sorts pushed to SQL
└──────────────────┘
Rules:
• Keep ≤ 5 pages × 100 rows = 500 rows hydrated in client at a time.
• Drop pages the user has scrolled far past (windowed cache).
• WebSocket pushes only update visible pages; others stay stale until scrolled to.
• Bulk-select across pages tracked by IDs, not by index.
Result on the same hardware:
| Metric | Before (naive) | After (this architecture) |
|---|---|---|
| TTI | 14.2s | 0.6s |
| Scroll FPS | 8 | 60 (steady, no drops over 10 minutes of scrolling) |
| INP (sort) | 3,400ms | 95ms |
| Memory (after 30 min of use) | 1.2 GB (crashed) | 110 MB (steady) |
| Bundle (gzipped) | 0 | +20 KB |
| User-reported "the grid is slow" tickets | 14/month | 0 |
12. Honest stuff
- Do server pagination before anything else. It''s the cheapest 10× improvement you''ll ever get.
- Virtual scrolling doesn''t fix memory if you''ve still loaded all the data. Pair it with pagination.
- Infinite scroll is a UX choice, not a perf choice. Make sure it''s the right UX before you reach for it.
- AG Grid is great; the bundle cost is real. ~270 KB matters on mobile and slow connections. Worth it when you need the features, expensive when you don''t.
- Headless beats opinionated for design systems. TanStack Table + your own markup keeps you in control.
- Bulk operations across pages are hard. Track selection by ID, not by index, and design the UX around "X selected" rather than "rows 1–50 selected."
- Live updates over a virtualized grid need care. Updates outside the visible window can wait; updates inside should be debounced.
13. Mental checklist
Before shipping a grid that might grow:
- You measured: TTI, scroll FPS, INP, DOM nodes, memory.
- Server pagination is cursor-based, not offset.
- Filters and sorts run on the server (with indexes).
- Virtual scrolling is enabled if any page can show > ~200 rows.
- Hydrated client state stays bounded as users scroll (windowed cache).
- Bulk-select tracks IDs, not indices.
- Keyboard nav scrolls non-rendered rows into view before focusing them.
- In-app search exists (Ctrl+F can''t find unrendered rows).
- Live updates outside the visible window are deferred.
- Bundle budget set and CI-checked for any grid library.
14. Closing — the right mental model
The right answer to "show me 250,000 rows" is almost never a single library — it''s a combination: server pagination + virtual scrolling + a sensible grid library on top. The naive single-technique reach ("just install AG Grid", "just paginate") works until it doesn''t; the combination above scales to millions of rows without trouble.
Three habits that make grids stay fast:
- Server pagination first, always. Whatever else you add, don''t ship a grid without it.
- Virtualize when any page renders more than ~200 rows. It''s the single biggest client-side win.
- Pick the library by feature need, not vibes. Need every feature → AG Grid. Want a custom design + minimum bundle → TanStack Table + react-virtual. Both are right; the question is which fits your constraints.
Apply that and "make the table faster" stops being a recurring ticket — it becomes a one-time architecture choice you measure once and live with for years.
Further reading
- TanStack Query — Infinite Queries — the canonical pagination/infinite-scroll companion.
- @tanstack/react-virtual — the virtualization library used here.
- TanStack Table — the headless table.
- AG Grid docs — Community + Enterprise, server-side row model.
- web.dev — INP — the interaction metric to optimize for.
- Shopify — Pagination with Relative Cursors — Shopify''s writeup on why cursors beat offsets.
Stuck choosing between AG Grid and TanStack Table for a real grid in your app, or trying to get scroll FPS up on an existing one? Email randhir.jassal@gmail.com with the dataset shape (size, columns, UX) and I''ll tell you which combination I''d ship.
Get the next issue
A short, curated email with the newest posts and questions.