Skip to content

View Servers & High-Performance Real-Time Data Rendering

A deep dive into the architecture pattern used by trading desks and financial UIs to render millions of ticking rows in a browser without melting it.


The Big Picture

This isn't one concept -- it's ~7 interconnected concepts that chain together into a full architecture pipeline:

Raw Data Source (market data, order books, positions)
        |
  [View Server]  <- interstitial server, computes derived values
        |
  [WebSocket]    <- full-duplex, subscribe/unsubscribe to windows
        |
  [Ingest Layer] <- receives deltas, writes to a mutable buffer
        |
  [Render Layer] <- pulls from buffer on a timer, renders if dirty
        |
     Screen

Each stage solves a different bottleneck. Every layer independently throttles -- the view server filters to the viewport, conflation drops intermediate values, batching caps frequency, the dirty flag skips redundant renders, and double buffering prevents tearing.


Concept Map

LayerConceptWhat it solves
ServerView Server (Vuu)Holds full dataset, computes derived cols, tracks what each client sees
ProtocolViewport subscription (row/col window)Client only asks for visible slice
ProtocolDelta streaming + conflationOnly send changes, latest-value-wins
ProtocolFrequency batching (1x/s, 4x/s)Cap update rate to something useful
ClientIngest/render separationWebSocket writes don't trigger renders
ClientDirty flag + pull renderingRender loop checks flag, skips if clean
ClientDouble bufferingWrite buffer and read buffer never conflict

1. Data Virtualization ("list virtualization but for data")

You probably know list virtualization (react-window, tanstack-virtual) -- you have 100,000 rows in a list but you only render the ~30 DOM nodes currently visible. The DOM is virtualized; the data is all in memory.

Data virtualization goes a step further: you don't even have all the data in the client. The client only knows about the rows visible on screen. If you have 2 million positions, why load all 2M into the browser? You only need the 30 the user can see.

This is exactly what AG Grid's Viewport Row Model does. The client tells the server "I need rows 0-29" and the server sends only those. When the user scrolls to row 500, the client says "now I need rows 500-529" and the server adjusts.

The canonical open-source implementation is Vuu (FINOS/Morgan Stanley) -- a dedicated "view server" built for exactly this pattern. On the Vuu server, each subscription creates a Viewport -- a lightweight data structure that holds pointers (indices) into the underlying data table. The viewport reflects the client's current sort/filter/group configuration, but the underlying data table is shared across all clients and subscriptions.


2. WebSockets & Full-Duplex Subscribe/Unsubscribe

HTTP is request-response. The client asks, the server answers. But with a WebSocket, both sides can send messages at any time (full-duplex). This enables a publish-subscribe pattern:

  • Client -> Server: SUBSCRIBE {table: "positions", viewport: {rows: 0-29, cols: ["symbol","qty","pnl"]}}
  • Server -> Client: pushes deltas whenever data in that viewport changes
  • Client -> Server: CHANGE_VP {rows: 50-79} (user scrolled)
  • Client -> Server: UNSUBSCRIBE (component unmounted)

In Vuu's architecture, a single WebSocket connection is shared across all subscriptions using a WebWorker. The ConnectionManager runs in a WebWorker so that message handling is off the main UI thread. Multiple UI components (e.g., two data grids showing different filtered views of the same table) each get their own Viewport on the server, but share one WebSocket.

Versioning / Race Conditions

There's a subtle race condition: when you change your viewport (scroll), stale data for the old range might arrive before the server acknowledges the change. Vuu handles this by attaching a requestId version to every CHANGE_VP message. Any server updates carrying a different version than the last acknowledged one are silently dropped. This prevents flicker from stale rows appearing during scroll.


3. Row Window Matrix (start row, end row, start column, end column)

This is the viewport subscription definition. Instead of subscribing to an entire table, you specify a rectangular window into the data:

json
{
  "startRow": 0,
  "endRow": 29,
  "startColumn": 0,
  "endColumn": 4
}

Why include columns? Because if you have 200 columns but only 6 are visible, why compute and transmit the other 194? This is particularly important when columns have expensive derived calculations.

AG Grid's viewport datasource calls setViewportRange(firstRow, lastRow) whenever the user scrolls -- telling the server exactly what slice of data the client needs. Buffer configuration (viewportRowModelPageSize and viewportRowModelBufferSize) controls pre-fetching beyond visible edges.


4. Constrain Deltas to the Window

This is the key optimization. The server holds the full dataset (millions of rows), but it only sends deltas (changes) for rows within the client's current viewport.

If row 5,000 updates but the client is looking at rows 0-29, the server ignores that update. If row 15 updates, the server sends: {row: 15, col: "price", value: 42.50}.

In Vuu, this is a core design principle: "Vuu favours the update path over the calculation of new data." When visible data changes, the update follows the tick() path through the system, propagating only within the viewport's bounds.

Without this, you'd be sending millions of updates per second to a client that can only display 30 rows. With it, even if the underlying dataset has 10,000 updates/second, the client might only receive 30-50 relevant deltas.

Conflation vs Queuing

The critical distinction: you don't queue throttled updates (that just delays the firehose). You conflate them -- meaning when it's time to send, you grab the latest value and throw away everything in between. Lightstreamer calls this MERGE mode. The user always sees current data, never stale queued data that hasn't caught up yet.

In Lightstreamer's MERGE mode, when an item gets its turn in a round-robin algorithm, it receives the very latest state available, not an old one. The frequency constraint is per-item (e.g., 0.5 updates/sec for each stock), while the bandwidth constraint is per-session (global). Both can be changed on the fly without resubscribing.


5. Update Frequency Batching (1x/s, 4x/s)

Even within the viewport, raw market data can tick faster than a human can perceive or a browser can render. If price updates 100 times per second, you don't need to render every tick.

The solution: batch deltas into fixed update intervals:

  • 4x/s (250ms intervals): good balance for most trading UIs
  • 1x/s: for less critical data (positions, P&L)
  • 10x/s: for active order book management

Human perception caps out at ~100-150ms intervals. Anything faster is invisible. So conflating to 4-10 updates per second loses no perceptible information while massively reducing processing load.

AG Grid implements this with applyTransactionAsync() which batches all pending updates and applies them together (default: every 50ms, configurable via asyncTransactionWaitMillis). AG Grid can process over 150,000 updates per second using this pattern.

Practical tip: rather than 10,000 individual events per second, send 50 messages with 200 updates each. Data changing every 20ms is past the speed our brains can process data.


6. Interstitial/Intermediate Compute Server ("doing computed stuff like notional")

Computing "notional" (= price * quantity) in an intermediate server. This is the view server itself -- it sits between the raw data source and the client.

Why not compute notional in the client?

  1. The client doesn't have all the data (remember, data virtualization)
  2. Computed columns might depend on other data the client doesn't have (e.g., FX rates for notionalInUsd = price * qty * fxToUSD)
  3. Server-side computation means sorting/filtering on derived columns works correctly across the full dataset

In Vuu, these are called Calculated Columns, defined inline:

"askNotional:Long:=ask*askSize"
"bidNotional:Long:=bid*bidSize"
"notionalInUsd:Double:=price*quantity*fxToUSD"

The server parses these into an AST and evaluates them, but only computes values for rows being sent through the viewport -- throttling computational costs. Supports string functions (len, upper, lower, contains), math functions (min, max, sum), and logic functions (if, or, and).

This pattern keeps the client thin. The view server acts as a materialized view layer -- like a database view but with real-time streaming.


7. Sync Engine / DuckDB ("having something like a sync engine db or duckdb to stream into")

For more complex client-side use cases, you can stream the viewport data into a local embedded database rather than directly into the grid. DuckDB-WASM runs in the browser and excels at analytical queries (aggregations, joins, filtering) with columnar-vectorized execution.

The pattern: WebSocket data streams into DuckDB in the browser, and your UI queries DuckDB locally. Benefits:

  • Client-side sorting/filtering/aggregation without server round-trips
  • Offline capability
  • Complex analytics (GROUP BY, window functions) on the data you've received
  • Acts as a local cache/buffer

FINOS Perspective is another tool in this space -- an in-browser analytical engine using WebAssembly + Apache Arrow that can handle streaming updates via table.update() calls, doing partial/indexed updates efficiently. It can tail streaming datasets, retaining only the latest N rows in constant memory.

Sync engines like PowerSync (Postgres-to-SQLite) and ElectricSQL implement incremental replication: only changes (deltas) are streamed to the client's local database, not full snapshots.


8. Separating Ingest from Rendering

This is the most important architectural principle. In a naive implementation:

WebSocket message -> setState() -> React re-render

At 1,000 messages/second, you'd trigger 1,000 React renders. The browser melts.

The fix: two separate loops:

Ingest loop (as fast as messages arrive):

  • WebSocket onmessage callback fires
  • Data written to a mutable ref (e.g., useRef in React)
  • A dirty boolean flag is set to true
  • No React state updates. No renders.

Render loop (on a fixed cadence, e.g., requestAnimationFrame or setInterval):

  • Check the dirty flag
  • If dirty: read from the ref, update the display cache, trigger a render, reset dirty to false
  • If not dirty: skip entirely

The ref lives outside React's state tracking, so writing to it triggers zero renders. React only sees one update per frame (or per interval), regardless of how many messages arrived.

Real-world example: DXcharts (Devexperts) uses RxJS as the data layer and React as pure rendering. Chart state lives as observable streams. Components are stateless UI renderers -- "the state lives in the RxJS layer and flows down to the components." Heavy data stays external to React components.

The "bypass React entirely" escape hatch

For single-value displays (like a price ticker), you can skip React's reconciliation loop altogether: priceRef.current.textContent = newPrice. The component renders once on mount, and after that you mutate the DOM directly through the ref. Zero React overhead per tick.


9. Dirty Flag Pattern with Refs

The dirty flag pattern is a boolean that tracks whether derived data is out of sync with primary data:

  1. When source data changes, set a boolean flag (dirty = true)
  2. When derived data is actually needed (e.g., at render time), check the flag
  3. If dirty, run the expensive recomputation and clear the flag. If not dirty, return cached results

Three conditions for using it:

  • Primary data changes more often than derived data is accessed
  • The computation is expensive
  • Incremental updates are not feasible
javascript
// Pseudocode pattern
const dataRef = useRef(initialData);
const dirtyRef = useRef(false);

// WebSocket handler (runs at message rate)
ws.onMessage = (msg) => {
  dataRef.current = mergeUpdate(dataRef.current, msg);
  dirtyRef.current = true;  // Mark dirty, don't trigger render
};

// Render loop (runs at display rate)
useAnimationFrame(() => {
  if (dirtyRef.current) {
    dirtyRef.current = false;
    setState(dataRef.current);  // Single render
  }
});

Pull-based vs push-based rendering: Push-based means every data change immediately triggers a re-render. Pull-based means the renderer runs on its own schedule and pulls the latest data when it's time to render. The dirty flag is the mechanism that enables pull-based rendering.


10. Data Double Buffering

Borrowed from graphics programming:

  • Back buffer (write buffer): where incoming WebSocket data is accumulated
  • Front buffer (read buffer / display cache): what the renderer reads from

The ingest loop writes to the back buffer. On the render tick, the buffers swap (or the back buffer is copied to the front buffer). The renderer only ever reads from the front buffer, so it always sees a consistent snapshot -- never a half-written state.

javascript
const backBuffer = useRef({})    // WebSocket writes here
const frontBuffer = useRef({})   // Renderer reads from here

// On render tick:
if (dirty.current) {
  // Swap: copy back -> front
  Object.assign(frontBuffer.current, backBuffer.current)
  dirty.current = false
  forceRender()  // trigger one React render
}

This guarantees:

  1. Ingest never blocks on rendering
  2. Rendering never reads partial/inconsistent data
  3. You can tune them independently (ingest: as fast as possible, render: 4x/s)

AG Grid's Value Cache is a related concept: computed values (from Value Getters) are cached and only recomputed for changed rows. When delta changes arrive, value getters only execute on new records. Even scrolling and opening/closing groups reuses cached values.


Why Canvas, Not DOM

Several of these systems (OpenFin's Hypergrid, DXcharts) skip React/DOM entirely for the actual grid cells and render to an HTML5 Canvas. A DOM grid with 30 visible rows and 10 columns = 300 DOM nodes to reconcile. Canvas = 1 element, draw calls only. For the highest performance tier, the grid itself bypasses React completely.


Full Architecture Diagram

                          SERVER SIDE
+--------------------------------------------------+
|  Raw Data Sources (market data, order feeds)      |
|         |                                         |
|    View Server (Vuu)                              |
|    +- Maintains full dataset in memory            |
|    +- Computes derived columns (notional, P&L)    |
|    +- Tracks each client's viewport window        |
|    +- Filters deltas to viewport bounds           |
|    +- Batches updates at configured frequency     |
+------------------+-------------------------------+
                   |  WebSocket (full-duplex)
                   |  subscribe/unsubscribe/change_viewport
                   |
+------------------v-------------------------------+
|                    CLIENT SIDE                    |
|                                                   |
|  WebWorker (off main thread)                      |
|    +- ConnectionManager (single WS, N viewports)  |
|              |                                    |
|         Ingest Layer                              |
|    +- Writes deltas to mutable ref (back buffer)  |
|    +- Optionally streams into DuckDB-WASM         |
|    +- Sets dirty = true                           |
|              |                                    |
|         Render Layer (separate loop)              |
|    +- Polls on interval (RAF / setInterval)       |
|    +- If dirty: swap buffers, render              |
|    +- If clean: skip (no wasted renders)          |
|              |                                    |
|         Virtualized Grid                          |
|    +- Only renders visible DOM rows               |
+---------------------------------------------------+

Key Technologies & Projects

ProjectWhat it isLink
Vuu (FINOS/Morgan Stanley)Open-source view servervuu.finos.org
Perspective (FINOS/J.P. Morgan)WebAssembly streaming query engineperspective.finos.org
AG GridEnterprise grid with viewport modelag-grid.com
LightstreamerReal-time data streaming middlewarelightstreamer.com
OpenFin / HypergridFinancial desktop OS + canvas gridopenfin.co
DuckDB-WASMIn-browser analytical databaseduckdb.org/docs/clients/wasm
Reactive Trader (Adaptive)Reference FX trading UIweareadaptive.com
PowerSyncPostgres-to-SQLite sync enginepowersync.com
DXcharts (Devexperts)Canvas-based financial chartingdevexperts.com/dxcharts