A React data grid that feels instant with 1,000 rows can freeze with 100,000 and crash the tab with a few million. The fix isn’t a faster grid alone. Good data grid performance at millions of rows comes from two techniques working together: buffered rendering, so the browser only draws what users can see, and server-side data, so the browser only holds what users need.
This guide explains why grids slow down at scale, how each technique works, how to choose between paging and infinite scrolling, and what changes once datasets reach the millions. The examples use ReExt, which brings the Ext JS grid into React, along with a server endpoint you can adapt to your own database.
Why data grids slow down at scale
Large datasets strain a grid in three places. Each one needs its own fix.
| Bottleneck | What goes wrong | The fix |
|---|---|---|
| Rendering | One DOM element per cell. A million rows by 20 columns is 20 million cells; React grid layout and scrolling grind to a halt. | Buffered rendering: draw only visible rows plus a small buffer. |
| Memory and network | Downloading millions of records takes time, and keeping them all in the browser uses a lot of memory. | Server-side data: load pages of rows on demand. |
| Processing | Sorting, filtering and grouping millions of records in JavaScript blocks the main thread, so the page stops responding. | Server-side processing: let the database sort, filter and aggregate. |
Buffered rendering solves the first problem on its own and is enough for many datasets. Past a certain size, the second and third problems take over, and the data itself has to stay on the server.
Technique 1: Buffered rendering
Buffered rendering, also called row virtualization, keeps only a small window of rows in the DOM: the rows on screen, plus a buffer above and below. As the user scrolls, the grid reuses those elements for new rows. The DOM stays the same size whether the dataset has 5,000 rows or 500,000, so scrolling stays smooth.
How it works in ReExt
In the Ext JS grid, buffered rendering is on by default, so a plain grid with a large local store already renders only what’s visible:
<ReExt
xtype="grid"
style={{ height: 520 }}
config={{
store: { data: largeLocalArray }, // e.g. 50,000 rows loaded once
columns: [
{ text: 'Order ID', dataIndex: 'id', width: 100 },
{ text: 'Customer', dataIndex: 'customer', flex: 1 },
{ text: 'Region', dataIndex: 'region', width: 110 },
{ text: 'Amount', dataIndex: 'amount', width: 130,
xtype: 'numbercolumn', format: '$0,000' },
],
}}
/>
The store still holds every row in memory, though. That’s fine for tens of thousands of rows, but not for millions, which is where the second technique comes in.
Getting the most from buffered rendering
- Keep row heights uniform. Virtualization is fastest when the grid can calculate row positions instead of measuring each row. Avoid wrapping text and variable-height cells in very large grids.
- Keep cell renderers light. A renderer runs for every visible cell on every scroll. Format values simply, and avoid heavy HTML or complex calculations inside renderers.
- Give the grid a fixed height. A virtualized grid needs a defined viewport. Without a height, it may try to render every row.
- Watch column count as well as row count. Fifty visible columns cost more per row than ten. Hide rarely used columns by default.
Technique 2: Server-side data
With server-side data, the full dataset never reaches the browser. The grid asks the server for one slice of rows at a time, sending the current sort and filters with each request. The server does the sorting and filtering, usually in the database where it’s fast and indexed, and returns only the requested rows plus a total count.
There are two ways to present server-side data: infinite scrolling and paging.
Infinite scrolling with a buffered store
Users scroll through the whole dataset as if it were loaded, and the grid fetches pages in the background as they approach. In ReExt, this is a buffered store:
store: {
type: 'buffered',
pageSize: 200, // rows per request
leadingBufferZone: 600, // rows to prefetch ahead of the scroll direction
trailingBufferZone: 200, // rows to keep behind
purgePageCount: 5, // pages to keep cached before discarding old ones
proxy: {
type: 'ajax',
url: '/api/orders',
reader: { type: 'json', rootProperty: 'data', totalProperty: 'total' },
},
autoLoad: true,
},
The buffered store always sorts and filters on the server, and it discards pages that are far from the viewport, so browser memory stays flat however far users scroll.
Paging
Users move through numbered pages with a toolbar. It’s simpler for your API and works well when users look up records rather than scan them:
store: {
pageSize: 100,
remoteSort: true,
remoteFilter: true,
proxy: {
type: 'ajax',
url: '/api/orders',
reader: { type: 'json', rootProperty: 'data', totalProperty: 'total' },
},
autoLoad: true,
},
bbar: { xtype: 'pagingtoolbar', displayInfo: true },
Infinite scrolling or paging?
| Infinite scrolling (buffered store) | Paging | |
|---|---|---|
| User experience | Continuous, like a spreadsheet | Discrete pages, easy to reference (“page 12”) |
| Best for | Scanning, monitoring and power users | Lookups, search results, reports |
| Server load | More, smaller requests while scrolling | One request per page |
| Grouping | Limited with buffered stores | Supported, with the server sorting by the group field |
| Jumping far ahead | Fast scrollbar drag, many skipped pages | Jump to a page number |
Also Read: React Grid Layout 2026: Complete Guide With JavaScript Grid Examples
Full example: a million-row orders grid
Here’s a complete JS grid for browsing millions of orders, with infinite scrolling, server-side sorting and filtering, and a live row count in React state. It assumes ReExt is installed and your app is wrapped in ReExtProvider, as covered in our Ext JS grid in React tutorial.
The React component
import { useMemo, useState } from 'react';
import ReExt from '@sencha/reext';
export default function OrdersExplorer() {
const [total, setTotal] = useState(null);
const config = useMemo(() => ({
title: 'All orders',
store: {
type: 'buffered',
pageSize: 200,
leadingBufferZone: 600,
fields: [
{ name: 'amount', type: 'float' },
{ name: 'orderDate', type: 'date', dateFormat: 'Y-m-d' },
],
proxy: {
type: 'ajax',
url: '/api/orders',
reader: { type: 'json', rootProperty: 'data', totalProperty: 'total' },
},
autoLoad: true,
listeners: {
totalcountchange: (count) => setTotal(count),
},
},
plugins: [{ ptype: 'gridfilters' }], // filters are sent to the server
columns: [
{ text: 'Order ID', dataIndex: 'id', width: 100, filter: 'number' },
{ text: 'Customer', dataIndex: 'customer', flex: 1, filter: 'string' },
{ text: 'Region', dataIndex: 'region', width: 110,
filter: { type: 'list', options: ['EMEA', 'APAC', 'AMER'] } },
{ text: 'Amount', dataIndex: 'amount', width: 130,
xtype: 'numbercolumn', format: '$0,000', filter: 'number' },
{ text: 'Order date', dataIndex: 'orderDate', width: 130,
xtype: 'datecolumn', format: 'M j, Y', filter: 'date' },
{ text: 'Status', dataIndex: 'status', width: 110,
filter: { type: 'list', options: ['Open', 'Shipped', 'Closed'] } },
],
}), []);
return (
<>
<p>{total === null ? 'Loading…' : `${total.toLocaleString()} orders`}</p>
<ReExt xtype="grid" style={{ height: 560 }} config={config} />
</>
);
}
Every sort and filter change triggers a new request; the grid never holds more than a few pages of rows.
The server endpoint
The grid sends start, limit, sort and filter parameters. This Node.js and Express endpoint turns them into a PostgreSQL query. Column names come from an allowlist and every value is parameterized, so user input never becomes raw SQL:
import express from 'express';
import pg from 'pg';
const pool = new pg.Pool(); // reads connection settings from environment variables
const app = express();
// Grid field -> database column. Anything not listed is ignored.
const COLUMNS = {
id: 'id', customer: 'customer', region: 'region',
amount: 'amount', orderDate: 'order_date', status: 'status',
};
app.get('/api/orders', async (req, res) => {
let sorters, filters;
try {
sorters = JSON.parse(req.query.sort || '[]');
filters = JSON.parse(req.query.filter || '[]');
} catch {
return res.status(400).json({ error: 'Invalid sort or filter parameter' });
}
const start = Math.max(0, Number(req.query.start) || 0);
const limit = Math.min(1000, Math.max(1, Number(req.query.limit) || 200));
const params = [];
const where = [];
for (const f of filters) {
const col = COLUMNS[f.property];
if (!col || f.value === '' || f.value == null) continue;
if (f.operator === 'in') { // list filters send an array
params.push([].concat(f.value));
where.push(`${col} = ANY($${params.length})`);
continue;
}
const ops = { lt: '<', gt: '>', eq: '=', like: 'ILIKE' };
const op = ops[f.operator] || '=';
params.push(op === 'ILIKE' ? `%${f.value}%` : f.value);
where.push(`${col} ${op} $${params.length}`);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const orderBy = sorters
.filter((s) => COLUMNS[s.property])
.map((s) => `${COLUMNS[s.property]} ${s.direction === 'DESC' ? 'DESC' : 'ASC'}`);
orderBy.push('id ASC'); // stable order so pages never overlap
try {
const rows = await pool.query(
`SELECT id, customer, region, amount, to_char(order_date, 'YYYY-MM-DD') AS "orderDate", status
FROM orders ${whereSql}
ORDER BY ${orderBy.join(', ')}
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, limit, start],
);
const count = await pool.query(`SELECT count(*)::int AS total FROM orders ${whereSql}`, params);
res.json({ data: rows.rows, total: count.rows[0].total });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Could not load orders' });
}
});
app.listen(3001);
Index the columns users sort and filter on most, such as order_date, region and status. Without indexes, the database sorts millions of rows on every request.
Choosing an approach by data size
These ranges are rules of thumb, not hard limits. Column count, cell complexity and users’ devices all shift them, so test with your own data.
| Dataset size | Recommended approach | Why |
|---|---|---|
| Up to about 10,000 rows | Load everything into a local store; buffered rendering handles the DOM | Simplest setup; sorting and filtering in the browser stay instant |
| About 10,000 to a few hundred thousand | Local store can work; test load time and memory, and move to the server if either struggles | Download size and in-browser processing start to show |
| Millions of rows | Server-side data: a buffered store for scrolling, or paging for lookups | The full dataset can’t reasonably reach the browser |
The tipping point usually isn’t rendering, which buffered rendering handles at almost any size. It’s the cost of downloading the data and processing it in JavaScript.
What changes at millions of rows
Moving data to the server keeps the grid fast, but some features need a different approach because the browser no longer has every row.
- Totals and summaries. Client-side summaries only cover rows the browser has loaded. For true totals, compute them in the database and return them with the response, or from a separate summary endpoint.
- Grouping. Group on the server, or use paging with the server sorting by the group field. For analytics across the whole dataset, aggregate in the database and show the results in a pivot or summary view.
- Export. “Export all” can’t come from the browser. Generate the file on the server with the same sort and filter parameters the grid sent.
- Select all. Selecting “all rows” should mean “all rows matching these filters,” sent to the server as filter parameters rather than millions of IDs.
- Counting. count(*) across millions of rows can be slow on some databases. Cache counts, use estimates for very large tables, or show “10,000+” until users narrow the filter.
- Deep paging. OFFSET gets slower the further users scroll, because the database skips more rows each time. Encourage filtering, and keep sort columns indexed. For APIs that only move forward, keyset (cursor) pagination avoids the problem.
How to measure data grid performance
Vendor benchmarks are a starting point. Your own measurements, with your data, columns and devices, are what count. Measure these five numbers for any grid you evaluate:
| Metric | What it tells you | How to measure |
|---|---|---|
| Time to first rows | How long users wait before they can read anything | Browser DevTools Performance panel, or timestamps around the first load event |
| Scroll smoothness | Whether scrolling stutters or shows blank rows | DevTools Performance recording while dragging the scrollbar; look for long frames |
| Request latency | How quickly the server returns a page with sort and filters applied | DevTools Network panel; time the slowest realistic filter |
| Browser memory | Whether memory stays flat during long scrolling sessions | DevTools Memory panel, before and after scrolling to the end |
| Interaction delay | How long sorting or filtering takes from click to updated rows | DevTools Performance recording of a sort and a filter |
Test on the hardware your users actually have. A grid that’s smooth on a developer workstation can stutter on an older office laptop.
Checklist: millions of rows, done right
- ☐ Buffered rendering is on, and the grid has a fixed height.
- ☐ Row heights are uniform and cell renderers are lightweight.
- ☐ Data loads from the server in pages; the browser never holds the full dataset.
- ☐ Sorting and filtering run in the database, on indexed columns.
- ☐ The API uses an allowlist for sort and filter columns and parameterized values.
- ☐ Totals, grouping and exports are computed on the server.
- ☐ Counts are cached or estimated for very large tables.
- ☐ Failed or slow requests show a clear message.
- ☐ Performance is measured with production-sized data on users’ real devices.
Frequently asked questions
Can a React data grid handle millions of rows?
Yes, with the right architecture. Buffered rendering keeps the DOM small, and server-side data keeps the dataset on the server, so the browser only loads and draws the rows users see. Trying to load millions of rows into the browser at once is what causes freezes and crashes.
What is buffered rendering in a data grid?
Buffered rendering, or row virtualization, renders only the rows in the visible area plus a small buffer, and reuses those elements as users scroll. The number of DOM elements stays roughly constant regardless of dataset size.
Should I use infinite scrolling or paging for large datasets?
Use infinite scrolling when users scan and browse data continuously, like in a spreadsheet. Use paging when users look up specific records or when your API and grouping needs are simpler. Both keep the full dataset on the server.
Why is my React table slow with large data?
Usually because every row is rendered to the DOM, the full dataset is loaded into the browser, or sorting and filtering run in JavaScript on the main thread. Turn on virtualization, move data and processing to the server, and keep cell renderers light.
How does ReExt handle large datasets?
ReExt uses the Ext JS grid, which renders rows in a buffered way by default. For very large remote datasets, a buffered store loads pages on demand as users scroll and sends sorting and filtering to the server. Both are part of the ReExt Pro plan.
Fast at any size
Millions of rows don’t have to mean a slow grid. Let buffered rendering handle the DOM, keep the data on the server, and push sorting, filtering, totals and exports into the database. The browser then only does what it’s good at: showing users the rows they’re looking at right now.
With ReExt, the Ext JS grid gives you both techniques as configuration: buffered rendering by default, and buffered or paged stores for server-side data, inside your React app.
Try it with your own data: start a 30-day ReExt trial, read the Enterprise React Data Grid Checklist, or see React Data Grid: The Complete 2026 Guide for Enterprise Apps.
