Published on

Real‑time WebSocket Data for a Trading UI: Ordering, Latency, and a Node.js Gateway

TL;DR

We need boards and graphs that stay visually coherent under volatile flow while data from multiple sources may arrive out of order. A Node.js gateway sequences, coalesces, and fanouts WebSocket streams, the React client renders derived projections within a strict latency budget.
This felt simple on paper, but the first volatility spike exposed every hidden assumptons.

Context and target architecture

A legacy Java/JSP monolith is split into: Java backend (multi‑source aggregation) → Node.js Gateway (WebSocket) → lightweight React UI.

The Gateway normalizes the wire format, enforces ordering, handles flow control, session resume, and fanout. More importantly, it hosts the product rules for what must look and feel consistent to the eye. I sketched neat flows on paper. They broke the first time feeds disagreed in timing, which was a good lesson in humility.

Data contract (minimal, stable)

Each message carries: topic, partition, sequence (seq), eventTime, sourceId, kind (snapshot|delta), version, checksum, and payload.

Amounts use integer minor units; nullability is explicit; fields are versioned for backward compatibility.
A stable contract turns debugging from guessing into a repeatble process. Debating units in the UI was a trap—we stopped and pushed that clarity into the contract where it belongs.

export type WireEnvelope<TPayload> = {
  topic: string;
  partition: number;
  seq: number; // strictly increasing per (topic, partition)
  eventTime: number; // epoch millis from source
  sourceId: string;
  kind: "snapshot" | "delta";
  version: number;
  checksum: string;
  payload: TPayload;
};

Ordering and consistency for out‑of‑order data

The backend aggregates multiple feeds, so the client can receive events out of order.
We maintain a per-(topic, partition) reorder buffer with a bounded window. Dplicates are discarded, stale gaps trigger a targeted resync. Snapshots plus idempotent deltas keep projections deterministic even under reconnects.

It’s tempting to ignore rare reordering, but it always bites at 2am during market moves 🤯🤯🤯

// Node.js Gateway: windowed reorder buffer per (topic, partition)
export function createReorderBuffer(windowSize: number) {
  type Key = string; // `${topic}:${partition}`
  const nextSeq = new Map<Key, number>();
  const buffers = new Map<Key, Map<number, unknown>>();

  function key(topic: string, partition: number): Key {
    return `${topic}:${partition}`;
  }

  function push<T>(topic: string, partition: number, msg: WireEnvelope<T>, onOrdered: (m: WireEnvelope<T>) => void) {
    const k = key(topic, partition);
    const expected = nextSeq.get(k) ?? msg.seq; // bootstrap
    if (msg.seq < expected) return; // stale or duplicate

    let buf = buffers.get(k);
    if (!buf) {
      buf = new Map();
      buffers.set(k, buf);
    }
    buf.set(msg.seq, msg);

    // Flush contiguous sequence
    let seq = expected;
    while (buf.has(seq)) {
      const m = buf.get(seq) as WireEnvelope<T>;
      buf.delete(seq);
      onOrdered(m);
      seq += 1;
    }
    nextSeq.set(k, seq);

    // Window bound: drop too-far-future messages
    if (buf.size > windowSize) {
      const lowest = Math.min(...buf.keys());
      const highest = Math.max(...buf.keys());
      if (highest - lowest > windowSize) {
        // trigger targeted resync upstream for this key
        buffers.delete(k);
        nextSeq.delete(k);
      }
    }
  }

  return { push };
}

Transport and flow control (Node.js Gateway)

We use a single WebSocket multiplexed by topic, with ping/pong, timeouts, and resume by last known seq.

Queues are bounded; non-critical streams use last‑write‑wins coalescing; batching and thresholded compression reduce head‑of‑line blocking. Priority lanes ensure user input beats market data when contention appears.

I wish we had bounded everything from day one; unbounded queues only defer pain, and predictible systems beat clever ones.

// Bounded broadcaster with LWW coalescing for non‑critical topics
export function createBroadcaster(maxQueue: number) {
  type Topic = string;
  type Client = { send: (data: string | ArrayBuffer) => void; ready: () => boolean };
  const topicToState = new Map<Topic, { queue: unknown[]; last?: unknown }>();
  const subscribers = new Map<Topic, Set<Client>>();

  function publish(topic: Topic, msg: unknown, lww = false) {
    const state = topicToState.get(topic) ?? { queue: [] };
    if (lww) state.last = msg;
    else state.queue.push(msg);
    topicToState.set(topic, state);
  }

  function tick() {
    for (const [topic, state] of topicToState) {
      const clients = subscribers.get(topic);
      if (!clients || clients.size === 0) continue;

      const batch = state.last !== undefined ? [state.last] : state.queue.splice(0, maxQueue);
      state.last = undefined;

      for (const client of clients) {
        if (!client.ready()) continue;
        for (const item of batch) client.send(JSON.stringify({ topic, item }));
      }
    }
  }

  function subscribe(topic: Topic, client: Client) {
    const set = subscribers.get(topic) ?? new Set<Client>();
    set.add(client);
    subscribers.set(topic, set);
  }

  return { publish, subscribe, tick };
}

Latency, clocks, and measurement

We track e2e RTT, queue lag, reorder rate, and render long tasks. A monotonic clock and server‑provided timestamps help separate event‑time from arrival‑time; an echo channel corrects offset drift. Staring at a histogram at 1am is not fun, but pairing latency with reorder histogrmas turned hand‑waving into real decisions.

React rendering pipeline (stable projections)

Inbound events form an append‑only log. We derive UI projections with pure reducers inside a Web Worker and deliver minimal diffs back to the main thread.
Delivery is frame‑coalesced and React transitions keep urgent input responsive even during bursts. Adding the demultiplexer for topic lanes felt like the only sane move once volatility kicked in ⚙️.

Honestly, the first refactor took longer than I wanted.

export function createFrameCoalescedStream<T>() {
  let pending: T | null = null;
  let listeners: Array<(value: T) => void> = [];
  let scheduled = false;

  return {
    push(value: T) {
      pending = value;
      if (!scheduled) {
        scheduled = true;
        requestAnimationFrame(() => {
          scheduled = false;
          if (pending !== null) {
            const v = pending;
            pending = null;
            for (const l of listeners) l(v);
          }
        });
      }
    },
    subscribe(cb: (value: T) => void) {
      listeners.push(cb);
      return () => {
        listeners = listeners.filter((x) => x !== cb);
      };
    },
  };
}
// React hook skeleton with Worker-backed derivations
export function useMarketData<TProjection>(worker: Worker) {
  const [state, setState] = React.useState<TProjection | null>(null);
  const stream = React.useMemo(createFrameCoalescedStream<TProjection>, []);

  React.useEffect(() => {
    const onMessage = (e: MessageEvent) => {
      stream.push(e.data as TProjection);
    };
    worker.addEventListener("message", onMessage);
    const unsub = stream.subscribe(setState);
    return () => {
      unsub();
      worker.removeEventListener("message", onMessage);
    };
  }, [worker, stream]);

  return state;
}

Observability and safety

We propagate trace IDs end‑to‑end, keep PII out of logs, and version schemas explicitly. Per‑topic histograms for lag and drops, plus a simple CPU budget on the client, kept the UI smooth under pressure. When this was missing, we were flying blind and it was frankly unnerving.

Tests and chaos

Contract tests validate the wire schema between Java ↔ Node ↔ React client; reducers use property‑based tests with permuted deltas to ensure idempotency where required.

We run record/replay sessions with injected jitter, latency, loss, and reorder. It was a hard job to build a dataset for the test that handle all the usecases and the first runs were … ugly. But clarity followed.
At the end the confidence boost was undeniable. The QA team did a big work on scenarios to complete and check every usecases and added stress test.

Finance lexicon (short notes to myself)

  • Order book: price‑level map of bids/asks; base for best bid/ask and depth.
  • Spread: difference between best ask and best bid.
  • Tick: smallest price increment.
  • Snapshot/Delta: full state vs incremental change messages.
  • PnL: profit and loss for a position or portfolio.
  • Slippage: execution price deviation vs expected.
  • Liquidity: ability to execute without significant price impact.
  • Partition: sub‑stream key for sequencing and parallelism.
  • Sequence (seq): strictly increasing counter per partition.
  • Coalescing: merging multiple updates into a representative one.
  • Backpressure: flow‑control to prevent overload by bounding queues.
  • Fanout: broadcasting to multiple clients/consumers.
  • Circuit breaker: safe‑mode that serves stable projections on failure.
  • Deduplication: dropping duplicate events.
  • Stale event: arrives but is older than the expected sequence.
  • Event‑time vs Arrival‑time: source emission time vs client receive time.
  • Resume token: last acknowledged seq used to resume a stream.
  • Reorder window: bounded buffer that reorders events within a range.
  • Throttle/Sampling: reducing update frequency or volume.
  • Decimation/Virtualization: rendering fewer points or DOM nodes for performance.

Note: This article remains somewhat general and does not delve into specific code or detailed architectural aspects, in order to preserve confidentiality.

Last updated
© 2025, Devpulsion.
Exposio 1.0.0#82a06ca | About