Skip to content

PivotViewer performance

PivotViewer splits its work across the main thread, a Web Worker, and a Pixi (WebGL) canvas. Knowing which part runs where tells you what your own code can slow down.

WorkWhere it runsWhen
Calling every dimension and filter getValue, building the column store and indexesMain threadWhenever data, dimensions, or filters change identity
Filtering, grouping, and sorting visible idsWeb Worker, or the main thread when no worker is availableOn every filter, dimension, or view change
Filter-panel option counts and numeric rangesMain thread, over all of dataWhenever data, filters, or a filter selection changes
Free-text searchMain thread, over the currently visible idsOn every search change
Card layoutMain threadOn grouping, zoom, or container size changes
Card drawingPixi, for cards near the viewportAs you zoom and scroll

Components publishes no item-count benchmarks for PivotViewer. The largest dataset in its Storybook stories has 2,500 items; that story demonstrates the component, it does not set a limit.

Filtering, grouping, and sorting requests are answered by a background Web Worker when one is available:

  • Main thread: extracts values, builds the column store, renders the UI, computes filter-panel counts and search
  • Worker thread: keeps its own copy of the store and indexes, and answers filter, grouping, and sort requests

This keeps the UI responsive while a filter or grouping recomputes. It does not move everything off the main thread: building the store, the filter-panel counts, and search still run there.

PivotViewer checks that the worker script is served as JavaScript before starting it. When it is not, when Worker is unavailable (for example during server rendering), or when the worker reports an error, the same computations run in-thread. The viewer keeps working; it only loses the off-thread benefit. Worker errors are logged to the console as [PivotEngine] Worker error:.

To use the worker, PivotViewer posts the column store to it, including the original data items. Keep items to plain, structured-cloneable data; large nested objects on each item add to that copy.

Each dimension and filter becomes one column, keyed by its key:

Row items:
[{ id: 'a', status: 'todo', priority: 3 }, { id: 'b', status: 'done', priority: 8 }, ...]
Columns built by PivotViewer:
status: ['todo', 'done', ...] (string column, with a value-to-ids index)
priority: Float64Array [3, 8, ...] (number column, with a sorted index for ranges)

Filters then work on item ids (Uint32Array) and only read the columns they need. The original items are kept alongside the columns for rendering and details, so PivotViewer holds both the items and one column per dimension and filter.

Cards are Pixi sprites, not DOM elements:

  • Only cards inside the viewport plus a buffer around it get a sprite
  • Sprites that leave that area are returned to a pool and reused for cards that scroll in
  • cardRenderer runs when a card’s sprite is filled, not for every item up front

Keep dimension and filter accessors simple. getValue runs for every item each time the store is rebuilt, and filter getValue runs again for every item whenever filter-panel counts are recomputed.

Good:

const status: PivotDimension<Task> = {
key: 'status',
label: 'Status',
getValue: (item) => item.status,
};

Avoid:

const status: PivotDimension<Task> = {
key: 'status',
label: 'Status',
// Runs for every item on every rebuild
getValue: (item) => expensiveCalculation(item),
};

Precompute expensive values when you load data, and read the precomputed property in getValue.

The cardRenderer returns lightweight structured data ({ title, labels?, values? }), so keep it cheap. For heavier detail content, render a memoized component from detailRenderer:

import { memo } from 'react';
const TaskDetails = memo(({ item }: { item: Task }) => (
<div className='task-details'>
<h4>{item.title}</h4>
<p>{item.description}</p>
</div>
));
<PivotViewer
data={tasks}
dimensions={taskDimensions}
cardRenderer={(item) => ({ title: item.title, values: [item.description] })}
detailRenderer={(item) => <TaskDetails item={item} />}
/>;

PivotViewer filters and searches only the items in data; it does not page or fetch. Give it the collection users actually need to explore, and narrow larger sets in your query before rendering (for example, by time range or scope).

If you append more items later, pass a new array. Each new data array rebuilds the store and indexes and shows “Building indexes…” while the worker catches up, so batch additions rather than appending one item at a time.

Cards cannot show images, so image cost only matters in your detailRenderer. Load large images or related data there, when the user opens a card, rather than putting them on every item in data: every item is copied to the worker.

Keep dimensions and filters stable. PivotViewer memoizes its column extraction on their identity, so a new array on every render rebuilds the store, re-posts it to the worker, and recomputes filter state.

Avoid:

<PivotViewer
data={tasks}
// Creates a new array on every render
dimensions={[{ key: 'status', label: 'Status', getValue: (item: Task) => item.status }]}
cardRenderer={taskCardRenderer}
/>

Better:

const dimensions = useMemo<PivotDimension<Task>[]>(
() => [{ key: 'status', label: 'Status', getValue: (item) => item.status }],
[],
);
<PivotViewer data={tasks} dimensions={dimensions} cardRenderer={taskCardRenderer} />;

Module-level constants work as well. The same applies to data: pass the same array until the items actually change.

Establish an application performance budget

Section titled “Establish an application performance budget”

Components does not publish universal item-count timing claims: load, filter, grouping, layout, and sprite cost depend on the data shape, accessors, card renderer, browser, device, and whether the worker path is available. Benchmark the representative production shape on the slowest supported device before choosing a maximum collection size.

Record at least:

  • time from data change to the first stable collection frame;
  • filter-update latency;
  • dimension/grouping-change latency;
  • main-thread long tasks while the worker is active;
  • worker-unavailable fallback latency;
  • memory after repeated data/filter changes;
  • zoom/pan frame rate with the representative card renderer.

Use fixed fixtures and repeat each run after warm-up. Keep the dataset, browser/device, build mode, and measurement method with the result so future releases can compare like-for-like. If a budget is exceeded, reduce the client collection before rendering, provide narrower searchFields, simplify the card renderer, or move more filtering into the application’s query/data preparation path.

  1. Performance tab: record an interaction and look for long main-thread tasks during data changes, filter-panel updates, and search
  2. Memory tab: take heap snapshots after repeated data and filter changes
  3. React DevTools: profile how often the component around PivotViewer re-renders and whether it passes new dimensions, filters, or data arrays

PivotViewer does not warn about dataset size. It logs errors to the console when the worker fails, when an in-thread fallback computation throws, or when Pixi fails to initialize (Failed to initialize Pixi.js:). A [PivotEngine] Worker error: entry means the viewer has switched to in-thread computation.

Scale by reducing what reaches the client rather than by tuning the component:

  • Filter and scope on the server or in the query, and pass PivotViewer only the working set
  • Precompute derived dimension values when loading data
  • Use fewer dimensions and filters; each one is a column and, for filters, a main-thread recount
  • Keep cardRenderer to a few short strings
  • Measure against your performance budget before raising the collection size

When users need to see or edit far more rows than the budget allows, a paged table such as DataTables is the better fit.

PivotViewer stores:

  • The original data array, on the main thread
  • One column per dimension and filter, and their indexes, on the main thread
  • A copy of the column store and the items in the worker, when the worker is used
  • Filter results as Uint32Array id lists
  • Pixi sprites for cards near the viewport, pooled for reuse

Components publishes no memory formula. Measure heap size with your own data, as part of the budget above.

  1. Profile before optimizing: Measure actual performance
  2. Optimize data structure: plain, flat items clone and extract faster
  3. Lazy load details: Fetch full item details only when selected
  4. Use production build: Development mode is slower
  5. Keep definitions stable: memoize dimensions, filters, and data
  6. Monitor memory: Check for leaks in long sessions
  7. Test on target devices: Mobile performance differs from desktop
  8. Set appropriate limits: Don’t try to visualize millions of items
  • Check data size
  • Optimize accessor functions
  • Reduce number of dimensions/filters
  • Preprocess data server-side
  • Check that data, dimensions, and filters are not new arrays on every render
  • Simplify filter accessors; they run on the main thread for filter-panel counts
  • Reduce dataset size
  • Check the browser console for [PivotEngine] errors
  • Check that the worker script is served with a JavaScript content type, so the worker path is used
  • Simplify card renderer
  • Keep card text short
  • Check for expensive computations in render
  • Check that the surrounding component does not re-render on every scroll
  • Check for memory leaks in detail components
  • Reduce data retention and the size of each item
  • Avoid replacing data with a new array when nothing changed
  • Narrow the collection before rendering