React at 1,000 updates per second: Streaming market data

Financial tickers are a useful stress test for frontend architecture because the data update rate is high, and the failures are visible immediately. The screen has to remain responsive while thousands of quotes arrive to update table cells and paint real-time canvas charts simultaneously.

In a typical React application, data flows downward through state updates. When a component receives new data (via hook or prop), React schedules render work, diffs the virtual DOM, and updates the real DOM.

This model fails under high-frequency streaming as React tries to trigger 1,000 state mutations per second, consuming the browser's main thread from short-lived state objects.

The goal is to separate feed ingestion from rendering, so the browser can receive quotes, but React only renders the latest visible state.

Architecture

The system is divided into two paths: cold state and hot state.

The database (Postgres) owns durable state: instruments, watchlists and initial quote snapshots. It hydrates the first view.

The feed service owns volatile state. It mutates an in-memory quote book, batches quote deltas, and broadcasts them over WebSocket.

  • Postgres: instruments, watchlists, initial quotes
  • Feed service: in-memory quote book, heartbeat, batched deltas (WebSocket)
  • Browser: row-level subscriptions, grid, canvas sparklines, batch requestAnimationFrame

The important part is that a quote update only becomes render work if it affects the latest visible screen.

Batching messages

The WebSocket server sends 20 messages per second, with roughly 50 quote deltas in each message. Each message contains deltas, not full replacement snapshots.

With TypeScript's exactOptionalPropertyTypes, we have to do an explicit merge. The reason is that the client quote book must preserve the previous value for omitted fields.


const next: Quote = {
  symbol: current.symbol,
  bid: delta.bid ?? current.bid,
  ask: delta.ask ?? current.ask,
  last: delta.last ?? current.last,
  volume: delta.volume ?? current.volume,
  change: delta.change ?? current.change,
  changePct: delta.changePct ?? current.changePct,
  ts: delta.ts
};

The WebSocket does not call setState.

It validates the payload, and sends deltas to the quote store.

socket.addEventListener("message", (event) => {
  const parsed = marketMessageSchema.safeParse(JSON.parse(String(event.data)));
  if (!parsed.success) return;

  const message = parsed.data;
  const gap = message.seq !== expectedSeq;
  expectedSeq = message.seq + 1;

  metricsStore.patch({ lastSeq: message.seq });
  metricsStore.add({ receivedMessages: 1, sequenceGaps: gap ? 1 : 0 });

  if (message.type === "snapshot") {
    quoteStore.ingestSnapshot(message.quotes);
    metricsStore.add({ receivedQuoteUpdates: message.quotes.length });
    return;
  }

  if (message.type === "delta") {
    quoteStore.ingestDeltas(message.quotes);
    metricsStore.add({ receivedQuoteUpdates: message.quotes.length });
    return;
  }

  if (message.type === "heartbeat") {
    metricsStore.patch({ lastHeartbeatAt: message.ts });
    return;
  }

  if (message.type === "status") {
    metricsStore.patch({
      connectedClients: message.connectedClients,
      serverUpdatesPerSecond: message.emittedUpdatesPerSecond,
      serverMessagesPerSecond: message.messagesPerSecond
    });
  }
});

Hot state (browser)

The browser keeps live quotes in an external mutable store. When a delta batch arrives, the store updates the latest quotes for each symbol, appends the latest price, marks the symbol dirty, and schedules one frame-aligned flush.


private scheduleFlush() {
  if (this.scheduled) return;

  this.scheduled = true;

  requestAnimationFrame(() => {
    this.flush();
  });
}

If ten updates for AAPL arrive before the browser paints, the quote grid needs to render the latest one (not ten intermediate AAPL prices).

Row-level subscriptions

Instead of subscribing to the full quote book, each row subscribes to one symbol.


subscribeSymbol = (symbol: string, listener: Listener) => {
  let listeners = this.symbolListeners.get(symbol);

  if (!listeners) {
    listeners = new Set();
    this.symbolListeners.set(symbol, listeners);
  }

  listeners.add(listener);

  return () => {
    listeners?.delete(listener);

    if (listeners?.size === 0) {
      this.symbolListeners.delete(symbol);
    }
  };
};

This keeps the row component as an ordinary UI component. React reads from the store through useSyncExternalStore, without putting the entire quote book in React state.


export function useQuote(symbol: string) {
  return useSyncExternalStore(
    (listener) => quoteStore.subscribeSymbol(symbol, listener),
    () => quoteStore.getQuote(symbol),
    () => quoteStore.getQuote(symbol)
  );
}

Virtualization

The grid renders only the visible window. This doesn't matter for a handful of rows, but it is a must when dealing with hundreds of symbols. There is no point rendering immediate ticks that will be replaced before the next paint. Do not mount rows that the user cannot see.

Canvas sparklines

This is an interesting piece that I spent a lot of time on. If every quote appended to React state and caused an SVG path to be recalculated, the implementation could become another high-frequency React workload. Instead, the quote store just keeps a ring buffer of recent prices per symbol.

The UI keeps enough points to show recent shape, keeping the ring buffer small and fixed (and lossy). The canvas component also subscribes to the same symbol as the row.


export function SparklineCanvas({ symbol, width = 120, height = 28 }: SparklineCanvasProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const rafRef = useRef<number | null>(null);

  useEffect(() => {
    ...
    const scheduleDraw = () => {
      if (rafRef.current !== null) return;
      rafRef.current = requestAnimationFrame(draw);
    };
    ...
    const unsubscribe = quoteStore.subscribeSymbol(symbol, scheduleDraw);
    return () => {
      unsubscribe();

      if (rafRef.current !== null) {
        cancelAnimationFrame(rafRef.current);
        rafRef.current = null;
      }
    };
  }, [symbol, width, height]);

  return (
    <canvas ref={canvasRef} className="sparkline" width={width} height={height} aria-label={`${symbol} price sparkline`} />
  );
}

CPU and Memory

The feed process mutates an in-memory quote book, serializes batches, and writes WebSocket messages. The browser parses messages, mutates the quote store, schedules frame work, runs React for changed rows, paints, and draws layout and canvas sparklines.

The important part is that the live stream does not wake the Next.js server. After the first page load, quote updates move from the feed service directly to the browser store over WebSocket.

ApplicationSize (MB)Type
Feed57.9RSS
Next.js67.2RSS

The feed process was roughly 58 MB RSS. The Next.js server process was roughly 67 MB RSS.

Multi-threading via Web Workers

While this setup effectively eliminates UI lag, WebSocket frame parsing and payload validation still consume the browser's resources. The upgrade here is to offload the parsing into a dedicated Web Worker.


Worker: 
  WebSocket connection
  JSON parse
  message validation
  delta coalescing 

Main thread: 
  external store mutation 
  React row rendering 
  canvas drawing

By pushing JSON parsing and message validation to its own thread, the main thread is reserved strictly for handling row transitions and rendering pixels.

Final Thoughts

The lesson here is that a high-frequency UI needs separate layers for feed, state mutation, scheduling, subscriptions, and pixel drawing. React should render the screen, not the stream.

Posted on Jun 01, 2026