What It Takes to Tell a Trader Their Risk Is Still Zero - Part 2: The UI
This is the second part of engineering notes from the Findev team building a pre-trade risk and allocation platform for a buy-side client. The first part was focused on backend problems they faced with.
Here we are going to talk about UI side. Stack: React, AG Grid.
What the grid actually has to survive
The backend graph from part one doesn't compute in isolation: it streams results into a live grid that a trader is reading in real time, sometimes while typing into it.
The main grid runs on the order of 200 (configurable) columns and up to 5,000 rows. The row count isn't the hard part: it's user edits and backend pushes landing on the same cells at once, across more than one grid on screen (main plus summary and pivot views) that all have to stay consistent.
Most users work across two windows: the full grid, and a smaller "simulator" view aggregating the same numbers to account level, a tenth as many columns. The usual flow checks the simulator first and drops into the full grid only when something looks off.
A grid that can render 5,000 rows once is a solved problem. A grid that can keep rendering correctly while both a user and a backend stream are actively fighting over the same cells, dozens of times a second, is not.
The performance work, piece by piece
Choosing a transport
Three approaches, tried in order:
- AMPS The original choice, a message bus built for this workload. Its State of the World feature, a queryable current value per record, went unused: computing render position under grouping was hard, and the grid's data was grouped throughout, so AMPS ended up serving as a plain data supplier. That, plus a weak data contract and an unreliable .NET client, is why it didn't last.
- gRPC-Web Protobuf contracts improved on ad hoc JSON, but every data type (calculations, cash flow, bogey overrides, custom securities) got its own stream, each opening its own HTTP request at startup and blocking the others. Multiplexing them into one connection fixed that, but gRPC-Web still didn't stick: weak community support around the library made it a harder bet to keep building on.
- SignalR Where the team settled: fits naturally with a C#/.NET backend, one WebSocket connection per session guarantees ordering, and type contracts generate straight from the API client.
This comparison was closer to timing one large session by hand than a rigorous load test, and the bottleneck was usually backend throttling, not the transport. Two side notes: protobuf stopped being a clear win once out of gRPC-Web, the browser handles JSON about as well; and GraphQL streams have gone as far as a prototype, appealing for a schema-first contract both sides could contribute to.
Taming throughput
Once update volume grew, three problems surfaced one at a time rather than all at once:
- Payload duplication: Account-level values repeat across every holding under that account. Fix: the protocol now sends shared account data once and reuses it, instead of repeating it per row.
- Stale updates: A user's edit can make an in-flight backend update obsolete before it arrives. Fix: per-allocation, per-column tag metadata (ID, timestamps), so an update older than what's on screen is skipped instead of applied and then visibly reverted.
- Bursts: Valid updates can still arrive in waves large enough to spike CPU and memory. requestAnimationFrame and requestIdleCallback were tried first and didn't hold up, the work still had to be chunked on the main thread. Fix: ingress and preprocessing moved into a Web Worker, off the main thread. It merges several updates to the same allocation into one (the grid overwrites the whole row anyway, so five in a row buys nothing over the last) and drops superseded messages under load.
That last fix alone cut session load time for 5,000 allocations from roughly ten seconds to about three and a half.
Rethinking state management
- Tried and dropped: Redux-heavy and MobX-heavy approaches from earlier iterations, both fought the domain's large objects, frequent updates, and mutation-shaped grid workflows.
- What's used now: a singleton RxJS orchestration service as the single source of truth. Consumers subscribe by change type, and every cycle it hands the grid three sets (added, changed, removed rows), matching AG Grid's own transactional delta model instead of forcing an immutable store onto it.
- What's kept separate: durable UI state (layout, workspace preferences) persists through its own debounced Redux middleware, on a separate path from real-time data.
Two phases, one interaction
User edits and backend recalculation are handled as two distinct flows rather than one:
- Phase 1, HTTP command. The user edits a cell; the change goes out as a CRUD update, and the session stream emits it immediately so the UI reacts without waiting on the round trip.
- Phase 2, WebSocket reconciliation. Once the backend finishes, SignalR pushes the results as deltas, checked against the tag metadata for staleness, and only the fields still valid get applied.
The result: an immediate optimistic reaction, then an authoritative correction, without the two visibly fighting. Closing the browser doesn't cancel an in-flight calculation either; only a newer request for the same session does, since the backend has no session state to lose.
Choosing and tuning the grid
What was tested. Several grid technologies and integration styles, run through a benchmark project kept isolated from day-to-day feature work:
- Perspective
- InfiniteGrid
- Glide
- AG Grid, plain JS
- AG Grid React
- AG Grid React, no custom cell renderers
- AG Grid React, native cell renderers
- AG Grid React, React cell renderers
How it was tested. Each candidate ran the same Playwright scripts against comparable datasets and update bursts, covering load, first paint, scroll, filter/sort, and high-frequency updates, run multiple times each for consistency, not just best case.
What won and why. AG Grid, on feature depth, with performance that became acceptable once tuned:
- Dropping React renderers on hot columns in favor of lighter render paths
- Static cell types instead of dynamic ones
- Fixed row heights
- A correctly sized virtualization window
- Debounced scroll handling
What was accepted as a trade-off. Vanilla-JS AG Grid skips React's render step, which is usually why a React integration lags the native library. Even tuned, this one doesn't fully close that gap: on a fast scroll the screen blanks for about a third of a second before catching up. Accepted rather than chased, since it hasn't drawn complaints from daily users.
A separate fix, not a trade-off. A tab can also hold pivot and summary tables beyond the main grid, twelve default layouts plus any number of custom ones. A realistic user keeps three or four open; a stress test might open twenty or thirty, and rendering that many at once, not the main grid's size, was what drove memory and CPU high enough to crash the tab. The fix: load the main grid first, then the rest one at a time.
Where things stand
The platform now handles both directions at once: a trader's edit lands immediately, the backend's recalculation reconciles a moment later, without either corrupting the screen. What's left is evolutionary: stronger streaming contracts (GraphQL), less flakiness in integration testing, firmer performance regression gates, and closing the stale-intraday-data gap on the backend.