Zoriah Cocio · Tucson, AZ · MMXXVI
← Back to notes
Note · Performance · 22 March 2026 · 6 min

Designing SpendBoard's Dashboard Performance Pipeline

How a fifty-thousand-row finance dashboard stays responsive — typed filter state, table virtualization, lazy charts, saved views as URLs, and the discipline of treating a 200ms interaction budget as a hard contract.

A dashboard that holds fifty thousand rows is only impressive if it stays fast. The work isn't the rows — it's the budget.

SpendBoard's design constraint is plain: the user is a finance operator. They will sort, filter, group, and scroll. They will do this for hours. The thing that makes the surface usable is not how it looks at first paint — it's whether interaction stays under 200 milliseconds at the end of a long day with every filter on. That number is a contract with the user. The rest of the architecture exists to honor it.

The filter graph is a typed object

Filter state lives in a single typed object that I serialize into the URL on every change. There is no useState for individual filter pills. There is one reducer, one shape, and one place that knows what "current view" means. The shape is small enough to round-trip through the address bar without compression:

// the entire view is one object, persisted into the URL
type View = {
  q?: string,
  vendor?: string[],
  category?: Category[],
  range?: { from: string, to: string },
  sort?: { col: ColId, dir: "asc" | "desc" },
  group?: ColId | null,
  cols?: ColId[],
};

The win is that "save a view" stops being a feature with a backend. The user copies the URL. The user shares the URL. The user bookmarks the URL. A saved view is shareable by the act of being viewed. The corollary is that everything else in the app — the table, the filter rail, the breadcrumb, the chart panel — derives its state from this single object. There is one source of truth, and it is in the address bar.

The table is virtualized, not paginated

Pagination is the wrong shape for finance data. Operators scan. They don't click through pages. The table uses TanStack Virtual to render only the rows that fit in the viewport, plus a small overscan buffer. Fifty thousand rows is roughly the same DOM cost as fifty — the rest are positions in a tall scroll container, not nodes.

The non-obvious work is in keeping virtualization stable under filter changes. When the filter set changes, the row count changes, which changes the scroll height, which can throw the user's scroll position into the wrong region. The fix is to anchor scroll position by row identity, not by pixel offset. After every filter change, the table looks up the row the user was reading and pins the viewport to it. The data shrinks underneath them; their place doesn't.

Charts are lazy and dismissible

Charts are loaded with a dynamic import, behind a panel that collapses to a 16-pixel rail. Most users keep the panel closed after the first session — the table is what they came for. By making the chart bundle lazy, the cost of having it is paid only by the users who open it. By making the panel collapsible, the cost of the chart is paid only by the users who want it.

The default state is closed. That's deliberate. Defaults are a design statement: they say this is what we think you want. The default for a finance dashboard is the table. Everything else is offered, not assumed.

The 200ms budget is enforced in CI

A budget that isn't measured is a wish. SpendBoard runs a small smoke test on every pull request: load the dashboard, apply three filters, sort by a column, and assert that every interaction completes within 200 milliseconds on a throttled CPU profile. If the budget is exceeded, the PR doesn't merge. It's the same pattern as a bundle-size budget — what gets measured, and what blocks a merge, is what survives.

The interesting effect is cultural. When the budget is a CI gate, you stop arguing about whether a feature is fast enough. You either ship under 200ms or you don't ship. The argument shifts to what to cut, which is a much more productive argument than how fast is fast.

What I'd do differently

I'd commit to the URL-as-state pattern earlier. The first version of SpendBoard had a small useViewStore wrapper around the filter object, and the URL was synced from it. That was an extra hop that broke half a dozen edge cases — back-button behavior, deep-linked filters, the moment a user pastes a URL into Slack. The right pattern is to treat the URL itself as the store, and the React state as a derived cache. The simplification is worth the small ergonomic loss.

I'd also build the smoke test before the third filter, not after. Performance budgets are easiest to defend when they exist before the surface that breaks them. The CI gate became more valuable in retrospect than I expected; I would write it on day one next time.