Performance
A grid that handles 100 rows fine but chokes at 50,000 isn't production-ready. OGrid ships with three complementary features to handle large datasets without framework-level heroics:
| Feature | Opt-in? | What it does |
|---|---|---|
| CSS Containment | No (automatic) | Browser skips layout and paint for off-screen cells |
| Column Virtualization | Yes | Renders only visible columns — scales to hundreds of columns |
| Web Worker Sort/Filter | Yes | Sort and filter run in a background thread — UI stays responsive |
For rendering only visible rows (usually the most impactful optimization for tall datasets), see Virtual Scrolling.
CSS Containment — you get this for free
Every grid cell already has contain: content applied — you don't configure anything. This tells the browser that what happens inside a cell doesn't affect anything outside it, so it can skip layout and paint work for cells not visible in the viewport.
/* Applied automatically to every body cell */
td.ogrid-cell {
contain: content; /* isolate layout + paint + style */
}
/* Pinned columns use position: sticky — containment relaxed to avoid conflicts */
td.ogrid-cell[data-pinned] {
contain: none;
}
Without row virtualization, OGrid also sets content-visibility: auto on rows, letting the browser skip off-screen row layout entirely:
/* Browser skips layout for rows not in the viewport */
tr[data-row]:not([data-virtual-scroll]) {
content-visibility: auto;
}
When row virtualization is active, spacer rows handle positioning instead — so content-visibility is not applied there. The data-virtual-scroll attribute on <table> controls this automatically.
Net result: even without opting into anything, a 500-row grid renders measurably faster than without these rules.
Web Worker Sort/Filter — keeps the UI responsive
Sorting 50,000 rows on the main thread can freeze the UI for a visible moment. With workerSort: true, the work happens in a background thread — the grid stays interactive while it processes.
<OGrid
columns={columns}
data={largeDataset} // 50,000 rows
getRowId={(r) => r.id}
workerSort={true}
/>
Full example
- React
- Angular
- Vue
- Vanilla JS
import { OGrid } from '@alaarab/ogrid-react-radix';
import type { IColumnDef } from '@alaarab/ogrid-react-radix';
interface Row {
id: number;
name: string;
revenue: number;
region: string;
}
const columns: IColumnDef<Row>[] = [
{ columnId: 'id', name: 'ID', type: 'numeric' },
{ columnId: 'name', name: 'Name', sortable: true },
{ columnId: 'revenue', name: 'Revenue', type: 'numeric', sortable: true },
{ columnId: 'region', name: 'Region', sortable: true },
];
function App() {
return (
<OGrid
columns={columns}
data={largeDataset} // e.g. 50,000 rows
getRowId={(r) => r.id}
workerSort={true}
statusBar
/>
);
}
Same props across all React packages — just change the import:
- Radix (lightweight, default):
from '@alaarab/ogrid-react-radix' - Fluent UI (Microsoft 365 / SPFx):
from '@alaarab/ogrid-react-fluent'- wrap in<FluentProvider> - Material UI (MUI v7):
from '@alaarab/ogrid-react-material'- wrap in<ThemeProvider>
import { Component } from '@angular/core';
import { OGridComponent, type IColumnDef } from '@alaarab/ogrid-angular-material';
interface Row {
id: number;
name: string;
revenue: number;
region: string;
}
@Component({
standalone: true,
imports: [OGridComponent],
template: `<ogrid [props]="gridProps" />`
})
export class GridComponent {
gridProps = {
columns: [
{ columnId: 'id', name: 'ID', type: 'numeric' },
{ columnId: 'name', name: 'Name', sortable: true },
{ columnId: 'revenue', name: 'Revenue', type: 'numeric', sortable: true },
{ columnId: 'region', name: 'Region', sortable: true },
] as IColumnDef<Row>[],
data: largeDataset, // e.g. 50,000 rows
getRowId: (item: Row) => item.id,
workerSort: true,
statusBar: true,
};
}
Same API across Angular packages. Change the import:
- Radix (CDK):
from '@alaarab/ogrid-angular-radix'(default, lightweight) - Angular Material:
from '@alaarab/ogrid-angular-material' - PrimeNG:
from '@alaarab/ogrid-angular-primeng'
All components are standalone — no NgModule required.
<script setup lang="ts">
import { OGrid, type IColumnDef } from '@alaarab/ogrid-vue-vuetify';
interface Row {
id: number;
name: string;
revenue: number;
region: string;
}
const columns: IColumnDef<Row>[] = [
{ columnId: 'id', name: 'ID', type: 'numeric' },
{ columnId: 'name', name: 'Name', sortable: true },
{ columnId: 'revenue', name: 'Revenue', type: 'numeric', sortable: true },
{ columnId: 'region', name: 'Region', sortable: true },
];
const gridProps = {
columns,
data: largeDataset, // e.g. 50,000 rows
getRowId: (item: Row) => item.id,
workerSort: true,
statusBar: true,
};
</script>
<template>
<OGrid :gridProps="gridProps" />
</template>
Same API across Vue packages. Change the import:
- Radix (Headless UI):
from '@alaarab/ogrid-vue-radix'(default, lightweight) - Vuetify:
from '@alaarab/ogrid-vue-vuetify'- wrap in<v-app>for theming - PrimeVue:
from '@alaarab/ogrid-vue-primevue'
import { OGrid } from '@alaarab/ogrid-js';
import '@alaarab/ogrid-js/styles';
const grid = new OGrid(document.getElementById('grid'), {
columns: [
{ columnId: 'id', name: 'ID', type: 'numeric' },
{ columnId: 'name', name: 'Name', sortable: true },
{ columnId: 'revenue', name: 'Revenue', type: 'numeric', sortable: true },
{ columnId: 'region', name: 'Region', sortable: true },
],
data: largeDataset, // e.g. 50,000 rows
getRowId: (r) => r.id,
workerSort: true,
statusBar: true,
});
Not sure about your data size? Use 'auto'
workerSort: 'auto' uses the worker only when your dataset exceeds 5,000 rows. For smaller datasets it runs synchronously — no overhead, no complexity:
<OGrid workerSort="auto" ... />
This is the right default if you're not sure how large your data will get.
How it works under the hood
- Before sort/filter, the grid extracts a flat value matrix from your row data.
- The matrix is sent to an inline Blob Web Worker via
postMessage. - The worker sorts/filters and returns the resulting row indices.
- The grid re-renders using those indices — no row data is ever copied back, only the index array.
The worker is created once per grid instance and reused for all subsequent sort/filter operations.
When the worker falls back to sync
The worker can't run in every situation. It falls back silently in these cases:
| Situation | Why |
|---|---|
Column has a custom compare function | Functions can't cross the worker boundary |
people filter type | Requires rich object comparison |
Worker API unavailable (old browser, sandboxed iframe) | No Worker API to use |
In all cases, the grid sorts and filters correctly — just on the main thread.
CSP gotcha
The worker uses an inline Blob URL. If your app has a Content-Security-Policy header, add blob: to worker-src:
Content-Security-Policy: worker-src 'self' blob:;
Without it, the browser blocks the worker and the grid falls back to synchronous processing. If you can't change the CSP, workerSort still works — it just runs on the main thread.
Putting it all together
Row virtualization, column virtualization, and worker sort/filter stack together cleanly. For a 100-column, 100,000-row dataset, enable all three:
- React
- Angular
- Vue
- Vanilla JS
import { OGrid } from '@alaarab/ogrid-react-radix';
function App() {
return (
<OGrid
columns={columns} // 100 columns
data={data} // 100,000 rows
getRowId={(r) => r.id}
virtualScroll={{
enabled: true, // renders ~30 rows instead of 100,000
rowHeight: 36,
overscan: 5,
columns: true, // renders only visible columns
columnOverscan: 2,
}}
workerSort={true} // sort/filter off the main thread
statusBar
/>
);
}
Same props across all React packages — just change the import:
- Radix (lightweight, default):
from '@alaarab/ogrid-react-radix' - Fluent UI (Microsoft 365 / SPFx):
from '@alaarab/ogrid-react-fluent'- wrap in<FluentProvider> - Material UI (MUI v7):
from '@alaarab/ogrid-react-material'- wrap in<ThemeProvider>
gridProps = {
columns, // 100 columns
data: largeData, // 100,000 rows
getRowId: (item: Row) => item.id,
virtualScroll: {
enabled: true,
rowHeight: 36,
overscan: 5,
columns: true,
columnOverscan: 2,
},
workerSort: true,
statusBar: true,
};
Same API across Angular packages. Change the import:
- Radix (CDK):
from '@alaarab/ogrid-angular-radix'(default, lightweight) - Angular Material:
from '@alaarab/ogrid-angular-material' - PrimeNG:
from '@alaarab/ogrid-angular-primeng'
All components are standalone — no NgModule required.
<script setup lang="ts">
const gridProps = {
columns, // 100 columns
data: largeData, // 100,000 rows
getRowId: (item: Row) => item.id,
virtualScroll: {
enabled: true,
rowHeight: 36,
overscan: 5,
columns: true,
columnOverscan: 2,
},
workerSort: true,
statusBar: true,
};
</script>
<template>
<OGrid :gridProps="gridProps" />
</template>
Same API across Vue packages. Change the import:
- Radix (Headless UI):
from '@alaarab/ogrid-vue-radix'(default, lightweight) - Vuetify:
from '@alaarab/ogrid-vue-vuetify'- wrap in<v-app>for theming - PrimeVue:
from '@alaarab/ogrid-vue-primevue'
import { OGrid } from '@alaarab/ogrid-js';
import '@alaarab/ogrid-js/styles';
const grid = new OGrid(document.getElementById('grid'), {
columns, // 100 columns
data: largeData, // 100,000 rows
getRowId: (r) => r.id,
virtualScroll: {
enabled: true,
rowHeight: 36,
overscan: 5,
columns: true,
columnOverscan: 2,
},
workerSort: true,
statusBar: true,
});
Which features do I actually need?
Start here, add as needed:
| Dataset | Recommended setup |
|---|---|
| < 1,000 rows, < 20 columns | Nothing — CSS containment handles it automatically. |
| 1,000 – 10,000 rows | Add row virtualization: virtualScroll: { enabled: true, rowHeight: 36 }. |
| > 10,000 rows | Row virtualization + workerSort: 'auto'. |
| > 30 columns with horizontal scroll | Add columns: true to the virtualScroll config. |
| Very large + very wide | All three: row virtualization, column virtualization, and workerSort: true. |
If you're unsure whether you need column virtualization, open DevTools and look at how many <td> elements render in a single row. If it's more than you can count comfortably, add columns: true.
Props reference
workerSort
| Value | When to use it |
|---|---|
false (default) | Small datasets, or when you have custom compare functions |
true | Large datasets where you want the worker always active |
'auto' | You don't know the data size ahead of time — use this as the safe default |
Column virtualization fields (inside virtualScroll)
| Field | Type | Default | Description |
|---|---|---|---|
columns | boolean | false | Enable column virtualization. Requires row virtualization to also be enabled. |
columnOverscan | number | 2 | Extra columns rendered beyond the visible range on each side (prevents blank flash on fast scroll). |
For the full virtualScroll API, see Virtual Scrolling — Props Reference.
Next steps
- Virtual Scrolling — row virtualization, scroll-to-row, and the full virtualScroll API
- Sorting — configure sortable columns and custom comparators
- Filtering — filter types and server-side filtering
- Server-Side Data — skip client-side processing entirely for the largest datasets
- Column Pinning — pinned columns always render regardless of column virtualization