A streaming analytics dashboard with charts and a data grid, a gray-blue British shorthair cat watching nearby.

Perspective: A WebAssembly Analytics Engine That Pivots Millions of Rows

The Gray Cat
The Gray Cat
0 views

Building an interactive analytics UI in the browser sounds simple until the data shows up. JavaScript-native grids and charts start to sweat past a few tens of thousands of rows, and the idea of pivoting, grouping, and aggregating millions of rows on the main thread is a recipe for a frozen tab. Add real-time streaming data on top, where new rows arrive every few milliseconds, and you are suddenly building a query engine and a rendering layer at the same time.

Perspective is the answer to that exact problem. It pairs a high-performance columnar query engine, compiled to WebAssembly, with a framework-agnostic UI component that lets users pivot, group, filter, aggregate, and switch between a data grid and a dozen-odd chart types. Originally built at J.P. Morgan for its trading business and later donated to the FINOS foundation, its DNA is real-time finance dashboards: large datasets, ticking updates, and end-users who want to reconfigure the view without writing a single query. The npm package @finos/perspective carries that pedigree into any web app.

Why Perspective Stands Out

Perspective is not a chart library and it is not just a grid. It is a data engine that happens to ship a beautiful UI on top.

  • A real query engine in the browser. The core is columnar, built on Apache Arrow, and compiled to WebAssembly. It pivots and aggregates millions of rows entirely client-side, running inside a Web Worker so your main thread stays smooth.
  • Built for streaming. Tables support incremental update() calls, indexed upserts, and ring-buffer limits. Views are reactive, so when the underlying data changes, every derived view and bound viewer updates incrementally rather than re-rendering from scratch.
  • User-configurable by design. Everything you can set through the API, a user can also set by dragging in the UI, and vice versa. The entire configuration serializes to a plain object you can save and restore.
  • More than ten visualizations. A virtualized data grid plus line, bar, area, scatter, heatmap, treemap, sunburst, and candlestick/OHLC charts.
  • A computed-column expression language. Define derived columns with a spreadsheet-like syntax based on ExprTK.
  • One engine, many runtimes. The same engine runs in the browser via WASM, in Node.js, in Python with a JupyterLab widget, and natively in Rust.

Getting It Installed

Perspective splits into a handful of packages: the engine, the viewer Custom Element, and the rendering plugins. You install the ones you need.

npm install @finos/perspective @finos/perspective-viewer \
  @finos/perspective-viewer-datagrid @finos/perspective-viewer-d3fc

Or with yarn:

yarn add @finos/perspective @finos/perspective-viewer \
  @finos/perspective-viewer-datagrid @finos/perspective-viewer-d3fc

A quick note on versions before you go further. There are two coexisting release lines right now. The @finos/perspective scope shown above is the familiar v3.x line that nearly every tutorial, example, and StackBlitz demo references. A newer v4.x line is migrating to the @perspective-dev/* scope with a reorganized package layout and a first-party React wrapper. The table, view, and viewer semantics carry over between them, but the import paths differ, so make sure the scope you install matches the docs you are following. This article uses the widely-deployed @finos line.

One more gotcha worth internalizing early: importing <perspective-viewer> on its own gives you an empty shell. The grid and charts live in separate plugin packages, and you must import them (and the theme CSS) for anything to render.

Three Primitives to Learn

Perspective's whole mental model rests on three objects. Once these click, everything else falls into place.

The Engine, the Table, and the View

A Client is your connection to an engine instance, obtained from a Web Worker for in-browser WASM. A Table is a named, typed, columnar dataset you can build from JSON rows, columnar JSON, a CSV string, or an Apache Arrow buffer. A View is a query over a table: group, split, aggregate, filter, sort, and computed expressions all live here. Views are reactive, so when the table updates, the view updates with it.

import perspective from "@finos/perspective";

interface Trade {
  id: number;
  region: string;
  product: string;
  revenue: number;
  cost: number;
}

const data: Trade[] = [
  { id: 1, region: "EMEA", product: "Bond", revenue: 1200, cost: 800 },
  { id: 2, region: "APAC", product: "Equity", revenue: 950, cost: 600 },
  { id: 3, region: "EMEA", product: "Equity", revenue: 1450, cost: 900 },
];

// 1. Get an engine client (WASM running inside a Web Worker)
const worker = await perspective.worker();

// 2. Create a Table, indexed by "id" so updates upsert in place
const table = await worker.table(data, { index: "id" });

// 3. Create a View to query the data programmatically
const view = await table.view({
  group_by: ["region"],
  aggregates: { revenue: "sum" },
  sort: [["revenue", "desc"]],
});

const result = await view.to_json();
console.log(result);

The index option matters more than it looks. With an index, a later update() that reuses an existing key overwrites that row in place rather than appending a duplicate. That single flag is what turns a Perspective table from a static dataset into a live, mutable one.

Binding Data to the Viewer

The query API is useful on its own, but the real magic is the <perspective-viewer> Custom Element. You drop it into your markup, load a table, and restore a configuration. From there your users take over.

import "@finos/perspective-viewer";
import "@finos/perspective-viewer-datagrid";
import "@finos/perspective-viewer-d3fc";
import "@finos/perspective-viewer/dist/css/themes.css";

const viewer = document.querySelector("perspective-viewer");

// Bind the table to the visual element
await viewer.load(table);

// Apply a full UI and query configuration in one call
await viewer.restore({
  plugin: "Datagrid",
  group_by: ["region"],
  split_by: ["product"],
  aggregates: { revenue: "sum", cost: "sum" },
  sort: [["revenue", "desc"]],
});

The restore() method takes the same configuration vocabulary as table.view(), plus viewer-level keys like plugin, columns, and theme. Because the user can change any of it by dragging in the UI, you will often want to capture their choices, which brings us to the configuration round-trip.

Saving and Restoring Layouts

A viewer's configuration is just a serializable object. Call save() to snapshot the current state and restore() to apply one. This pairing is what makes saved dashboards and shareable layouts almost free.

// The user dragged columns around; capture their layout
const layout = await viewer.save();
localStorage.setItem("dashboard-layout", JSON.stringify(layout));

// Later, or in another session, hand it back
const saved = localStorage.getItem("dashboard-layout");
if (saved) {
  await viewer.restore(JSON.parse(saved));
}

// React to changes as the user makes them
viewer.addEventListener("perspective-config-update", async () => {
  const current = await viewer.save();
  console.log("user reconfigured the view", current);
});

That perspective-config-update event fires whenever a user pivots, filters, or switches charts, so you can persist their layout without a save button anywhere in sight.

Pushing It Further

The basics get you a configurable analytics view. The next layer is where Perspective earns its finance heritage.

Streaming Real-Time Updates

Because tables are mutable and views are reactive, streaming is a matter of calling update() on a loop. The viewer and every derived view refresh incrementally. With an indexed table, repeated keys upsert; with a limit table, old rows roll off like a ring buffer.

// Indexed table: updates with an existing id overwrite that row
const liveTable = await worker.table(initialRows, { index: "id" });
await viewer.load(liveTable);

// A market-data feed arriving a few times per second
function onTick(tick: Trade) {
  // Only the changed rows are sent; the engine reconciles the rest
  liveTable.update([tick]);
}

// Or a ring-buffer table that keeps only the latest 10,000 rows
const tape = await worker.table(schema, { limit: 10_000 });

No full re-ingest, no full re-render. The engine reconciles just the changed rows and pushes incremental updates out to anything bound to the table. This is the behavior that lets Perspective drive ticking dashboards without dropping frames.

Computed Columns with Expressions

You do not have to pre-compute derived values before they reach Perspective. The expression language lets you define computed columns inline, using a spreadsheet-like syntax, and they participate fully in grouping, filtering, and aggregation.

const marginView = await table.view({
  group_by: ["region"],
  expressions: {
    margin: '"revenue" - "cost"',
    margin_pct: '("revenue" - "cost") / "revenue" * 100',
  },
  aggregates: { margin: "sum", margin_pct: "avg" },
  filter: [["margin", ">", 100]],
});

Columns are referenced by name in double quotes. Because expressions are evaluated inside the WASM engine, a computed margin column behaves exactly like a real one, and your users can pivot or filter on it as if you had shipped it in the source data.

Going Virtual for Truly Huge Data

In-browser WASM handles millions of rows, but some datasets simply do not belong in a browser. Perspective supports a virtual or remote mode where the engine runs server-side in Python, Node.js, or Rust, and the browser connects over a WebSocket. The viewer stays thin, queries execute on the server, and only the visible, aggregated slice crosses the wire.

import perspective from "@finos/perspective";

// Connect to a remote engine instead of a local Web Worker
const websocket = await perspective.websocket("ws://localhost:8080");
const remoteTable = await websocket.open_table("market_data");

await viewer.load(remoteTable);
await viewer.restore({ plugin: "Y Bar", group_by: ["region"] });

From the viewer's perspective, a remote table and a local one are interchangeable. You write the same load and restore calls, and the engine quietly decides whether the work happens in the user's tab or on a server handling a dataset far too large to ship. This is also the seam where Perspective can sit in front of external engines like DuckDB or Polars, translating the viewer's UI into native queries.

One Cleanup Rule You Cannot Skip

Perspective objects are backed by WebAssembly memory that the garbage collector cannot reclaim for you. When you are finished with a table, a view, or a viewer, call its delete() method. Forgetting this is the single most common way to leak memory in a Perspective app.

// When a component unmounts or a dashboard closes
await view.delete();
await table.delete();
await viewer.delete();

If you wrap the viewer in a React component, run these in your cleanup effect so they fire on unmount. The newer v4 React package handles this for you, but in any hand-rolled integration it is your responsibility.

Wrapping Up

Perspective occupies a rare spot in the ecosystem. It is heavier than a lightweight grid or a single charting library, and that WASM bundle has a real startup cost, so it is overkill for a small static table. But when you need users to freely pivot millions of rows, when data streams in faster than you can re-render, or when you want Python notebooks and a browser dashboard sharing one engine, very little else comes close. You get a query engine, a streaming data model, and a polished configurable UI in one package, instead of stitching those three layers together yourself. For real-time and large-scale analytics in the browser, that is a trade well worth making.