Every reporting screen gets the same request after its first week: "Can I see this by month instead?" Then by product, then by country and month together. A pivot table is the answer that stops the requests, because the user drags the fields where they want them and gets the report they were about to ask for.
The catch is the data. Every drag regroups and re-sums every row. With a few thousand rows nobody notices. With a million, the obvious implementation freezes the page while it works, and a page that freezes on every drag is a page people stop dragging in.
This post measures that on real data: the 1,067,371 invoice lines of the Online Retail II dataset, pivoted in the browser with our free PivotGrid. The demo times every recompute in your own browser, so you can check each number here against your machine.
The report a user builds
Start from revenue by country and year, then drag Month into the columns or Product into the rows. Each drop recomputes the whole table:
In React that is one component and a list of fields; area says where each field starts, and the user moves them from there:
import { BasePivotGrid } from '@kanunilabs/pivotgrid-react';
import '@kanunilabs/pivotgrid-react/styles.css';
const fields = [
{ id: 'country', dataField: 'country', caption: 'Country', dataType: 'string', area: 'row' },
{ id: 'year', dataField: 'year', caption: 'Year', dataType: 'string', area: 'column' },
{ id: 'revenue', dataField: 'revenue', caption: 'Revenue', dataType: 'number', area: 'data', summaryType: 'sum' },
{ id: 'month', dataField: 'month', caption: 'Month', dataType: 'string', area: 'filter' }, // waiting to be dragged
{ id: 'product', dataField: 'description', caption: 'Product', dataType: 'string', area: 'filter' },
];
<BasePivotGrid gridId="sales" data={rows} initialFields={fields} />;
What a drag costs
The demo measures two things on every recompute:
- How long it takes, from the moment the pivot starts to the moment the new table is painted.
- The longest pause on the page's main thread in that window. The browser's Long Tasks API reports every task of 50 ms or more, and the main thread is the one that also handles your clicks and keys. Rendering the new table counts too.
For country by year, or sales against cancellations by country, a recompute took 29 to 48 ms and no task on the main thread reached 50 ms. Drag Month into the columns and Year out, about 40 countries by 25 months, and it took 45 to 82 ms over three runs, with the longest pause between none and 54 ms:
The grouping and summing run in a Web Worker, a second thread the page starts for the pivot. The main thread only receives the finished totals and draws them.
The obvious way, measured
To see what the worker buys, the demo has a button that computes the same country-by-year revenue the way a quick implementation would: one pass over every row with a Map, on the main thread.
const totals = new Map<string, number>();
for (const r of rows) {
const key = `${r.country}|${r.year}`;
totals.set(key, (totals.get(key) ?? 0) + r.revenue);
}
That loop froze the page for about 110 ms, longer than the pivot's whole recompute. Its totals are the same as the pivot's: £7,511,064 for the United Kingdom in 2011 and £134,790 for France in 2010, which we also checked with an independent computation over the source file.
A tenth of a second does not sound like much until you type through it. The demo has a text box for that: type while you drag fields, and the letters keep appearing; type while the loop runs, and they arrive all at once when it ends.
The worker does not make the work free. It moves it off the thread that answers the user.
Where the page stopped, and the fix it led to
Put Product in the rows and Month in the columns, about 5,000 products by 25 months, sorted by name, and the measurement changed: on October 7 the recompute took 350 to 650 ms and the main thread paused for 290 to 510 ms.
The aggregation was still in the worker, so we looked at what was left on the main thread. Sorting the same table by revenue instead of by name cut the pause to 105 to 183 ms. The difference was how the 5,000 product names were compared:
// what the pivot's sort did before 1.7.2
names.sort((a, b) => a.localeCompare(b, locale, { numeric: true }));
// the same order, with the collator built once
const collator = new Intl.Collator(locale, { numeric: true });
names.sort(collator.compare);
Both give the same order. On 5,000 names, about 60,000 comparisons, the first took 206 to 226 ms and the second 4 to 5 ms, on the same machine. Called with an options object, localeCompare sets up its comparison rules again on every call; a collator sets them up once.
We shipped the second version in PivotGrid 1.7.2 on October 8 (@kanunilabs/pivotgrid-react 1.4.2). Measured again in the live demo, five loads each, the layout sorted by name now takes 151 to 196 ms with a longest pause of 97 to 118 ms, the same as sorted by revenue. You can check it at /demos/pivot?layout=product-month&sort=name.
If you sort strings anywhere a user waits, a table header, a filter list, a dropdown, this one is worth checking in your own code.
The remaining 100 or so milliseconds were in the React grid, not in the pivot. After the first paint it measured the panel widths and header heights and kept them in React state, so the roughly 1,400 visible cells were drawn a second time in the same task, only to change two paddings and a few CSS variables. It also wrote the row-header width variables on the grid's outer box, where every cell inherits them, so each write invalidated the style of every cell and the next measurement forced a full style recalculation. Now the measurements go through refs and the variables sit only on the panel that reads them. On a local production build of this demo, ten switches to Product × Month, the median longest pause went from 89 to 51 ms and the recompute from 155 to 102 ms. That fix shipped in @kanunilabs/pivotgrid-react 1.5.0 on October 9.
Measure your own pivot
You do not need our component to get these two numbers. Any pivot or table that recomputes on user input can be measured the same way:
const longTasks: PerformanceEntry[] = [];
new PerformanceObserver((list) => longTasks.push(...list.getEntries()))
.observe({ type: 'longtask', buffered: true });
const t0 = performance.now();
await recompute(); // whatever your table does on a drag
requestAnimationFrame(() => requestAnimationFrame(() => {
const t1 = performance.now(); // the new table has been painted
const pause = Math.max(0, ...longTasks
.filter((t) => t.startTime + t.duration >= t0 && t.startTime <= t1)
.map((t) => t.duration));
console.log(`recompute ${t1 - t0} ms, longest main-thread pause ${pause} ms`);
}));
If the second number is large, your users feel it, whatever the first one says.
What this costs
- It is free. Drag and drop, filters, sorting, % of totals and saved views are in the Community package, with no licence key and no watermark. Calculated fields in code and the rows behind a clicked cell are free too; the formula designer for end users, rendered charts, the ready-made drill-down window and styled Excel/PDF export are in the paid edition.
- The data has to be in the browser. The demo downloads a 5.5 MB file once and decodes it into a million row objects before the first pivot. For data that should stay on the server, the paid edition can ask your server for the aggregates instead.
- Wide layouts still pause the page. For 5,000 products by 25 months, after the two fixes above, the median longest pause was 51 ms on our local production build; slower machines see more. The panel in the demo shows what yours does.
Try it
The demo is at /demos/pivot; /demos/pivot?layout=product-month opens the wide layout, best sellers first, and adding &sort=name sorts it by name. The PivotGrid docs cover fields, filters, summary types and the React and plain JavaScript setups.
Data: Online Retail II, UCI Machine Learning Repository (Chen, 2019), CC BY 4.0.
Disclosure: this article was drafted with AI assistance. The demo and every number in it were measured against our packages and the published dataset in October 2026.




Top comments (0)