Getting Started 10,000,000 rows 47 columns up to 20,000 columns

Ten Million Rows, Both Ways

An order warehouse with ten million rows and 46 columns, built the way an operations screen actually is: grouped headers, cells that draw rather than print, colour that comes from the numbers, totals over the whole result set, flags with their own menu, and a server doing the sorting. Both axes are yours to push: pick a dataset size, then hand the grid up to twenty thousand more columns and watch what stays flat. Every figure on this page is measured while you use it.

The Grid

Scroll down and rows arrive 200 at a time. Scroll sideways and columns arrive the same way, under the 9 group headers they belong to. Right-click a row for its menu, flag one with the button at the end of it, and read the strip above: those are sums over everything the query matched, not over the rows the browser is holding.

The Columns control is the width axis of the same question. On top of the 46 real columns it appends generated metric columns, a thousand at a time or any number you type, up to 50,000. Watch the two numbers in the strip below: the columns drawn stay in the twenties whatever the total is, and the cells in the DOM stay where they were. What does move is the swap itself, which is the cost of the column list rather than of the columns.

Three of the eight groups fold, and they fold the two different ways a group can. Money keeps its place in the row and shows Total alone; Dates and Quality go behind a drawer, as do the metric columns, so twenty thousand of them are put away and brought back in one click. The drawer holds no data, which is why nothing exports or copies out of it.

The row under the header filters where you are looking. On a server row model every field is one request, debounced, so typing in it is the same round trip the toolbar's search makes; what one field cannot hold, a range or a second condition, reads back as a summary with a button to the panel.

Status, Priority and Note are editable: double-click, commit with Enter, and the change is PATCHed back and kept.

Live orders service
Rows
Columns

Orders matched

-

Revenue

-

Refunded

-

Avg margin

-

On time

-

0 of 0 rows walked, 200 at a time

In the DOM 0 rows
Columns drawn 0 of 46
Cells 0
Requests 0
Round trip 0ms
Scan / sort 0 / 0ms
Order
Handling
Customer
Where
Product
(any)
(any)
(any)
(any)
(any)
(any)
(any)
(any)
(any)
(any)
(any)
(any)
(any)
(any)
(any)

No data

0 rows

What Is On The Table

Six things above are not data. They are what turns a list of values into something an operations team reads all day, and each one is a few lines rather than a subsystem.

Grouped headers that fold

9 groups over 46 columns. Money folds down to Total and keeps its place; Dates and Quality fold away behind a drawer, and so do the metric columns, however many of them there are

A filter row on a server model

One field per column under the header, in the operator that column already uses. Each keystroke is one debounced request, and a filter the row cannot hold reads back as a summary that opens the panel

Cells that draw

A cell snippet is a component: the avatar, the margin bar, the refund tooltip and the row actions are each one

Conditional formatting

One cellDecoration hook paints a negative margin red and a risk score over 80 the same, per rendered cell

Flags, a menu and a shortcut

One feature carries the state, the right-click entries and Ctrl+G, without the grid knowing what a flag is

Totals over the result set

Revenue and margin are summed on the server across every matching row, and arrive after the first window

The chrome, composed by hand

Quick filter, chips, export, density and the status bar are one toolbar prop; they are written out here for one reason, which is that the column chooser lists every column and steps aside above the real ones

<script lang="ts">
  // 1. GROUPED HEADERS. A column with children becomes a group header
  // spanning its leaves, and resizing the group distributes the change.
  const columns: ColumnDef<Order>[] = [
    { id: 'group-money', header: 'Money', children: [
      { id: 'total', header: 'Total', type: 'currency', cell: totalCell },
      { id: 'margin', header: 'Margin', cell: marginCell }
    ]}
  ];

  // 2. CONDITIONAL FORMATTING and 3. FLAGS are one feature each. Neither
  // needs anything the built-ins do not also use.
  const heatmap = (): GridFeature<Order> => ({
    id: 'heatmap',
    cellDecoration: ({ node, column }) =>
      column.id === 'margin' && node.row.margin < 0
        ? { class: 'bg-error/10 text-error font-medium' }
        : undefined
  });

  const orderFlags = (): GridFeature<Order> => ({
    id: 'order-flags',
    createState: () => new OrderFlags(),
    menuItems: ({ grid, node }) => node ? [{
      id: 'toggle-flag', label: 'Flag this order', icon: 'lucide:flag',
      onSelect: () => getFlags(grid)?.toggle(node.id)
    }] : [],
    keybindings: [{ key: 'Ctrl+g', handler: (grid) => /* ... */ }]
  });
</script>

<!-- 4. CELLS THAT DRAW. A cell snippet is a component: it gets the row,
     the value and the column, and renders whatever you like. -->
{#snippet marginCell({ value }: DataGridCellContext<Order>)}
  <span class="flex items-center justify-end gap-2">
    <span class="h-1.5 w-12 rounded-full bg-on-surface/10">
      <span class="block h-full rounded-full {value < 0 ? 'bg-error' : 'bg-success'}"
            style="width: {Math.abs(value) * 160}%"></span>
    </span>
    <span class="tabular-nums">{pct.format(value)}</span>
  </span>
{/snippet}

<!-- 5. THE CHROME. One prop brings the quick filter, the filter chips, the
     column chooser, the density toggle, the export menu and the status bar. -->
<DataGrid {grid} toolbar class="h-144" />

The Other Axis: Twenty Thousand Columns

Ten million rows is the vertical claim. The horizontal one is the same claim turned ninety degrees, and it is worth making separately because the two cost different things. Rows are cheap to add and expensive to fetch. Columns are the reverse: nothing has to be fetched for a column that is computed, and what grows instead is the list itself, the widths resolved from it, and the CSS grid template every rendered row declares.

So the metric columns above are generated rather than declared, and their cells are computed in the browser from the row id. That is not a shortcut around the server: a chunk of 200 rows across twenty thousand columns is four million values on the wire, and no amount of windowing on the client makes a response smaller. Sorting and filtering a metric still happen on the server, because a row index is all either side needs to agree on a value.

Two things are worth watching while you push the control. The columns drawn stay in single figures at every width, which is what column virtualization is for. And the column chooser disappears once the metrics are on, because it lists every column it can hide, and twenty thousand checkboxes in a menu is not a menu: at that width, hiding columns is a job for setColumnHidden and a search of your own.

Export is the other thing that walks every column rather than the visible ones, and it stays: measured here, the context menu's Export CSV over the 200 loaded rows across 20,048 columns takes about 0.8s and hands back a file nobody wants. It is worth knowing where that line is. Past it, a wide grid exports on the server through onExportAll, the same way it fetches.

Columns heldSwapColumns drawnCells in the DOM
48-7161
1,04813 to 14ms7161
5,04831 to 49ms7161
20,048195ms7161
50,048929ms7161

A headless Chrome at 1600x1000, one width after another in the same session, twice over: the two runs are where the ranges come from. Two columns move and two do not, which is the whole point: the swap grows with the list, while the cells drawn are the same 161 at fifty thousand columns as at forty-eight. Scrolling into the middle of the twenty thousand takes it to 322, because a narrower column means more of them fit, and back to 253 at the end. The table is 2,257,074 pixels wide at that width, and the scrollbar covers it in one drag.

// The width axis. A metric column is generated on demand rather than
// declared: its value is the same hash the real columns use, with a salt
// past the end of them, so nothing about it is stored on either side.
function metricDef(index: number): ColumnDef<WarehouseRow> {
  const source = extraColumnAt(index);          // { id: 'm41', header: 'M42', kind }
  return {
    id: source.id,
    header: source.header,
    sortable: true,
    filter: 'number',
    align: 'right',
    width: 110,
    // Computed where it is drawn. The server sends the 46 real columns and
    // nothing else: 200 rows across 20,000 metrics would be four million
    // values on the wire, and windowing the render does not shrink a
    // response. This is the one cost column virtualization cannot save.
    accessor: (row) => valueAt(Number(row.id) - 1, source.id)
  };
}

// Swapping the whole list is one assignment, and it is what costs: the
// cells stay windowed, but resolved widths, prefix sums and the CSS grid
// template every row declares all scale with the total.
grid.columns.defs = [...realColumns, ...Array.from({ length: 20_000 }, (_, i) => metricDef(i))];

// initialColumns bounds the first paint, before there is a viewport to
// window by. Without it a grid this wide renders every column for a frame.
virtualization({ rowHeight: 44, overscan: 8, columns: { initialColumns: 24 } });

// Sorting and filtering a metric still happen on the server, because a row
// index is all either side needs to agree on the value:
sortKeyAt(row, 'm41');            // the same uint32 key the radix sort takes
valueAt(row, 'm41');              // what a filter compares

What Makes It Possible

None of this is the grid being clever about ten million rows. The grid never sees them. Each side does the part it can do cheaply, and the numbers above are what that costs.

Five decisions

The decisionWhy
The rows are computed, not storedEvery value is a pure function of the row index, so ten million rows cost no memory and no boot time. A real warehouse reads from disk instead; the shape of the work is the same, and this way the demo fits in a docs site
Filtering is a scanOne pass over the index range, comparing dictionary indices rather than building strings. Ten million rows land in a few hundred milliseconds because nothing is allocated per row
Sorting is a radix sort, not a comparatorSorting ten million indices through a JS comparator costs about four seconds. Four passes of eight bits over uint32 keys costs about half of one, which is why every column exposes a sort key as an integer
Totals come from the server, and arrive lateA footer that sums the 200 rows the browser holds is worse than no footer. These are exact sums over every matching row, which at ten million costs about two seconds, so they are a separate request and the grid paints without them
Only the visible cells exist46 columns is nearly seven thousand pixels of table. Row and column virtualization together mean a row is a dozen cells, and the per-cell hooks only ever run on those
Folding is how a wide table stays readableTwenty thousand columns are not a reading problem so much as a navigation one. A group folded to a drawer takes its columns off the table and leaves one strip to bring them back, so the table between them closes up rather than being scrolled past
A metric column is generated, not declaredThe twenty thousand columns the width control adds do not exist in a schema anywhere. Each one is the same hash the real columns use with a salt past the end of them, so the browser computes the cells it draws and the server computes the ones it sorts and filters by, and the two agree without either sending anything
Column virtualization saves the rendering, not the payloadWhich is why the metric cells are computed in the browser rather than fetched: 200 rows across 20,000 columns is four million values on the wire, and no windowing on the client makes that request smaller. A real wide grid either computes what it can or asks for the columns it is showing
What a wide grid costs is its column listNot its columns. Resolved widths, prefix sums and the CSS grid template every row declares all scale with the total, which is what the column-swap timer measures. The cells drawn, and the hooks that run on them, stay flat

The Fetch Loop

Infinite scroll under a server row model is one watcher and one guard. The watcher reads the range the virtualizer is rendering; the guard stops a second request while one is in flight. The append is wrapped in untrack because the row count is what the range is measured against, so a tracked write would feed the read that triggered it.

The request this page last sent:

none yet
import { createDataGrid, getVirtualization, virtualization } from '@sv5ui/datagrid';

const grid = createDataGrid<Order>({
  columns, data: [], getRowId,
  rowModel: 'server',
  features: [sorting(), filtering(), columnOps(), selection(), editing(),
             // columns: true, so 46 columns do not become 46 cells per row
             virtualization({ rowHeight: 40, overscan: 8, columns: true })]
});

const virtual = getVirtualization(grid)!;
const CHUNK = 200;
const THRESHOLD = 60;   // how close to the end the window may come

let inFlight = false;
let exhausted = false;

// The watcher. It reads the range being rendered and appends when the end
// comes near. The append is untracked: the row count is what the range is
// measured against, so a tracked write would feed the read that triggered it.
$effect(() => {
  const end = virtual.virtualizer.range.end;
  untrack(() => {
    if (inFlight || exhausted) return;
    if (end + THRESHOLD >= grid.data.length) void fetchChunk(grid.data.length);
  });
});

// Sorting and filtering decide which rows exist, so what is loaded is no
// longer in the right order. Throw it away and ask again from the top.
for (const event of ['sortChanged', 'filterChanged'] as const) {
  grid.events.on(event, () => {
    exhausted = false;
    grid.data = [];
    void fetchChunk(0);
  });
}

async function fetchChunk(offset: number) {
  inFlight = true;
  try {
    const response = await fetch('/api/orders', {
      method: 'POST',
      body: JSON.stringify({
        // quickFields: the columns a bare query applies to, so hiding one
        // narrows the search on the server too. nulls: which end blanks
        // belong at, so the order survives the wire.
        filter: toFilterRequest(
          getFiltering(grid)?.model ?? { quick: '', columns: {} },
          grid.columns.visible.map((column) => column.id)
        ),
        sort: toSortRequest(
          getSorting(grid)?.sort ?? [],
          grid.columns.defs,
          getSorting(grid)?.nulls
        ),
        offset,
        limit: CHUNK
      })
    });
    const { rows, total } = await response.json();

    grid.data = offset === 0 ? rows : [...grid.data, ...rows];
    grid.api.setRowCount?.(total);       // what the footer and the announcer count
    if (rows.length < CHUNK) exhausted = true;
  } finally {
    inFlight = false;
  }
}

The Backend

The endpoint is a route in this site. It scans a range of integers rather than an array of rows, sorts with a radix sort over uint32 keys, and caches the resulting index list under the query that produced it. The request shape is not invented for the demo: it is what toFilterRequest and toSortRequest produce.

That request carries two things the server would otherwise have to guess. quickFields names the columns a bare query applies to, so hiding a column in the chooser stops it being searched here rather than only on screen, and nulls says which end blanks belong at, so the order does not change on the way across the wire.

Measured on this machine at ten million rows: a filter scan is a few hundred milliseconds, a full sort is around half a second, and the exact totals are about two seconds, which is why they are a separate request and why both they and the result they sum are cached. Every window after the first is free.

// Ten million rows the server never holds: a row is a pure function of its
// index, so a query is a scan over a range of integers.

function valueAt(row: number, columnId: string): unknown {
  const salt = columnIndex(columnId) + 1;
  const r = hash(row, salt);              // well-mixed, deterministic
  // ...one branch per column kind: dictionary, int, money, date, bool, stars
}

// FILTER: one pass, nothing allocated per row. Ten million rows in tens of
// milliseconds because no object is ever built for a row that does not match.
const hits = new Int32Array(size);
let count = 0;
for (let row = 0; row < size; row++) {
  if (quick && !quickFields.some((f) => contains(valueAt(row, f), quick))) continue;
  if (columnFilters.every((f) => f.test(valueAt(row, f.columnId)))) hits[count++] = row;
}

// SORT: a radix sort over uint32 keys. A comparator sort of ten million
// indices costs about four seconds; four passes of eight bits costs about
// half of one. Every column exposes its order as an integer key for exactly
// this reason: text packs its first four characters, a date is a day number,
// a dictionary column is its index in a sorted dictionary.
indices = radixSortByKey(indices, (row) => sortKeyAt(row, field));

// CACHE: the query resolves to a list of row indices, held under a signature
// of itself. Scrolling then asks for window after window of that list and the
// server does no work beyond materializing the 200 rows it returns.
cache = { signature: JSON.stringify({ size, filter, sort }), indices, total };

const rows = [];
for (let i = offset; i < Math.min(offset + limit, total); i++) {
  rows.push(rowAt(indices ? indices[i] : i));
}
return json({ rows, total });

Writing an Edit Back

cellEdited carries the row id, the column and both values, which is everything a PATCH needs. The server keeps edits as a sparse overlay keyed by row index, so only what someone changed costs anything.

The grid holds the new value as soon as the editor commits, so the table does not wait for the response. A failed write surfaces above the grid.

// An edit is a write. The event carries everything a PATCH needs, so the
// write path is a listener rather than a wrapper around the editor.

grid.events.on('cellEdited', ({ rowId, columnId, oldValue, newValue }) => {
  void save(rowId, columnId, newValue);
});

async function save(id: string, column: string, value: unknown) {
  const response = await fetch('/api/orders', {
    method: 'PATCH',
    body: JSON.stringify({ id, changes: { [column]: value } })
  });
  if (!response.ok) {
    // The grid already holds the new value, so a failure is yours to
    // surface. Refetching the row is the honest undo.
    error = 'The edit did not reach the server';
  }
}

// The column decides what the editor is, and validation runs before commit,
// so an invalid value never reaches this listener.
const columns: ColumnDef<Order>[] = [
  {
    id: 'status',
    header: 'Status',
    editable: true,
    type: 'badge',
    typeOptions: { colors: { paid: 'success', pending: 'warning', failed: 'error' } },
    editor: { type: 'select', options: [{ label: 'paid', value: 'paid' }] }
  }
];