Home / Articles / Responsive trading UIs with SSE, virtualization, and a 20-market budget

This article is published in English.

Responsive trading UIs with SSE, virtualization, and a 20-market budget

Coordinate React virtualized rows, a central SSE subscription planner, and an external quote store so open tickets keep live prices without exceeding connection limits.

1870 words

A trading blotter can list thousands of instruments while only a few rows are on screen. Quotes stream continuously, the user scrolls, and an open order ticket may still need live prices after its market leaves the viewport.

Those concerns have different clocks:

Scrolling changes visibility.
Subscription policy changes network demand.
SSE changes quote data.
React changes what is displayed.

Give each concern a single owner. The excerpts below sketch that split; they are architectural slices, not a full trading client.

Define the rules before choosing the implementation

Assume the backend allows at most 20 market IDs on one SSE connection. Policy then becomes:

  • One active EventSource per browser tab
  • Never more than 20 distinct market IDs on that connection
  • An open order ticket outranks ordinary list rows
  • Demand can change without tearing down the consumer registration
  • Live quotes live outside React component state
  • Incoming frames are validated before they mutate the store
  • Submit paths re-read the latest quote, not only the last painted one
  • Expired or disconnected quotes cannot be used to submit

The architecture

Virtualized list ─── visible and nearby market IDs ──┐
                                                   ├─► Subscription manager
Open order ticket ─── selected market ID ───────────┘             │
                                                                 ▼
                                                          One SSE connection
                                                                 │
                                                          Validated messages
                                                                 ▼
                                                             Quote store
                                                            /           \
                                                           ▼             ▼
                                                  React snapshots   Order validation

React components declare demand and read snapshots. They do not open or own sockets.

1. Store current quotes independently of React

Validated wire quotes look like:

type Quote = Readonly<{
  marketId: string;
  bid: string;       // Decimal strings preserve wire precision.
  ask: string;
  version: number;
  staleAt: number;   // Server expiry timestamp in milliseconds.
  tradable: boolean;
}>;

Snapshots expose readiness as well as payload:

type QuoteSnapshot =
  | { state: "waiting"; quote: null }
  | { state: "fresh" | "stale"; quote: Quote };

The store surface stays small:

interface QuoteStore {
  receive(quote: Quote): void;
  // Immediate state, used by commands.
  readLatest(marketId: string): QuoteSnapshot;  // Cached visual snapshots, used by React.
  select(marketId: string): {
    subscribe(notify: () => void): () => void;
    getSnapshot(): QuoteSnapshot;
  };  invalidate(marketIds: readonly string[]): void;
  retain(marketIds: readonly string[]): void;
}

Receive logic compares versions before accepting an update:

function receiveQuote(next: Quote) {
  const previous = latestQuotes.get(next.marketId);
  // This example assumes strictly increasing quote versions.
  if (previous && next.version <= previous.version) return;  latestQuotes.set(next.marketId, next);  // Independent of whether another price message arrives.
  scheduleQuoteExpiry(next.marketId, next.staleAt);  // Ordinary prices can wait for the next visual publication.
  scheduleVisualPublication();
}

Expiry timers, invalidation, and fan-out to subscribers live in the store. History is not unbounded—only the latest quote per market is retained:

Market A, version 10
        ↓ replaced
Market A, version 11
        ↓ replaced
Market A, version 12

If the server reuses version spaces across frame types, the reducer must merge according to that contract.

2. Allocate the 20 subscriptions centrally

Consumers advertise demand with a priority:

type Demand = {
  marketIds: readonly string[];
  priority: number;
};
const PRIORITY = {
  orderTicket: 0,
  visible: 1,
  nearby: 2,
} as const;const MAX_MARKETS = 20;

A planner collapses duplicates and keeps the strongest priority per market:

function planSubscriptions(
  demands: readonly Demand[],
  activeIds: readonly string[],
): string[] {
  const priorities = new Map<string, number>();
  for (const demand of demands) {
    for (const marketId of demand.marketIds) {
      priorities.set(
        marketId,
        Math.min(
          priorities.get(marketId) ?? Infinity,
          demand.priority,
        ),
      );
    }
  }  if (priorities.size === 0) return [];  const active = new Set(activeIds);  const requested = [...priorities.keys()].sort(
    (left, right) =>
      priorities.get(left)! - priorities.get(right)! ||
      Number(active.has(right)) - Number(active.has(left)),
  );  return [...new Set([...requested, ...activeIds])]
    .slice(0, MAX_MARKETS)
    .sort();
}

When demand exceeds twenty markets, surplus rows stay visibly waiting. The UI must not pretend they have live coverage.

3. Update demand without restarting a React effect

Each consumer gets a stable owner:

function useMarketDemand(
  marketIds: readonly string[],
  priority: number,
) {
  const [owner] = useState(() =>
    subscriptionManager.createOwner(),
  );
  const key = JSON.stringify([...new Set(marketIds)]);
  const stableIds = useMemo<string[]>(
    () => JSON.parse(key),
    [key],
  );  useEffect(() => {
    owner.update({
      marketIds: stableIds,
      priority,
    });
  }, [owner, stableIds, priority]);  useEffect(() => {
    return () => owner.dispose();
  }, [owner]);
}

ID changes update that owner in place; unmount cleans it up.

Rapid demand churn is batched inside a fixed scheduling window:

let reconciliationTimer:
  ReturnType<typeof setTimeout> | undefined;
function scheduleReconciliation() {
  if (reconciliationTimer !== undefined) return;  reconciliationTimer = setTimeout(() => {
    reconciliationTimer = undefined;
    reconcileSubscriptions();
  }, 150);
}

Because the window is fixed rather than a sliding debounce, continuous scrolling cannot postpone reconciliation forever. First connect and final teardown may still run immediately.

4. Replace the SSE connection safely

New query parameters mean a new EventSource. Close the previous stream before opening the next:

let source: EventSource | undefined;
let generation = 0;
let clearWatchdog: (() => void) | undefined;
function replaceConnection(marketIds: string[]) {
  const currentGeneration = ++generation;  clearWatchdog?.();
  source?.close();
  source = undefined;  quoteStore.retain(marketIds);
  quoteStore.invalidate(marketIds);  if (marketIds.length === 0 || !navigator.onLine) return;  const query = new URLSearchParams();  for (const marketId of marketIds) {
    query.append("marketId", marketId);
  }  const nextSource = new EventSource(
    `/api/v1/stream?${query.toString()}`,
  );  source = nextSource;
  const permittedIds = new Set(marketIds);  const watchdog = createSilenceWatchdog(() => {
    if (currentGeneration !== generation) return;    nextSource.close();
    reconcileSubscriptions({ force: true });
  });  clearWatchdog = watchdog.stop;
  watchdog.reset();  nextSource.addEventListener("quote", event => {
    if (currentGeneration !== generation) return;    // Parses JSON and validates it against the feed contract.
    const quote = parseQuoteMessage(event);    if (!quote || !permittedIds.has(quote.marketId)) {
      quoteStore.invalidate(marketIds);
      return;
    }    watchdog.reset();
    quoteStore.receive(quote);
  });  nextSource.addEventListener("heartbeat", () => {
    if (currentGeneration === generation) {
      watchdog.reset();
    }
  });  nextSource.onerror = () => {
    if (currentGeneration !== generation) return;    quoteStore.invalidate(marketIds);    if (nextSource.readyState === EventSource.CLOSED) {
      watchdog.stop();
      reportConnectionError();
    }    // Recoverable failures are retried by native EventSource.
  };
}

Watchdog, parser, and connection-status helpers stay in separate modules. A generation counter drops late events from obsolete sockets. Native EventSource reconnects for recoverable errors; calling .close() ends that instance. See MDN’s guide on using server-sent events for the browser behavior.

Online/offline and page-lifecycle listeners sit at manager scope: going offline invalidates quotes and closes the socket; recovery reconnects from current demand. A fresh connection waits for fresh quotes before enabling submit.

5. Virtualize the list and report its viewport

Virtualization limits mounted DOM; the subscription planner separately limits streamed markets. With TanStack Virtual, visible rows can request higher priority than overscan neighbors:

function MarketList({
  markets,
  onOpenTicket,
}: {
  markets: Array<{ marketId: string; name: string }>;
  onOpenTicket(marketId: string, side: "BUY" | "SELL"): void;
}) {
  const [viewport, setViewport] =
    useState<HTMLDivElement | null>(null);
  const virtualizer = useVirtualizer({
    count: markets.length,
    getScrollElement: () => viewport,
    getItemKey: index => markets[index]!.marketId,
    estimateSize: () => 72,
    overscan: 2,
  });  const rows = virtualizer.getVirtualItems();
  const range = virtualizer.range;  const visibleIds = range
    ? markets
        .slice(range.startIndex, range.endIndex + 1)
        .map(market => market.marketId)
    : [];  const nearbyIds = rows.map(
    row => markets[row.index]!.marketId,
  );  useMarketDemand(visibleIds, PRIORITY.visible);
  useMarketDemand(nearbyIds, PRIORITY.nearby);  return (
    <div
      ref={setViewport}
      role="region"
      aria-label="Markets"
      tabIndex={0}
      style={{ height: 560, overflow: "auto" }}
    >
      <div
        style={{
          height: virtualizer.getTotalSize(),
          position: "relative",
        }}
      >
        {rows.map(row => (
          <div
            key={row.key}
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              width: "100%",
              height: 72,
              transform: `translateY(${row.start}px)`,
            }}
          >
            <MarketRow
              market={markets[row.index]!}
              onOpenTicket={onOpenTicket}
            />
          </div>
        ))}
      </div>
    </div>
  );
}

These examples assume fixed row height; variable heights need measurement. The virtualizer mounts rows; subscription policy remains application-owned (see TanStack Virtual’s React docs). Keyboard focus should also keep a focused row available when it leaves the ordinary render window.

6. Render each market through its own snapshot

function useQuote(marketId: string) {
  const selection = useMemo(
    () => quoteStore.select(marketId),
    [marketId],
  );
  return useSyncExternalStore(
    selection.subscribe,
    selection.getSnapshot,
  );
}

Until something meaningful changes for that market, select keeps handing back the identical snapshot reference—exactly what React expects from an external store used with useSyncExternalStore.

const MarketRow = memo(function MarketRow({
  market,
  onOpenTicket,
}: {
  market: { marketId: string; name: string };
  onOpenTicket(marketId: string, side: "BUY" | "SELL"): void;
}) {
  const snapshot = useQuote(market.marketId);
  const quote = snapshot.quote;
  const available =
    snapshot.state === "fresh" && quote?.tradable;  return (
    <article>
      <strong>{market.name}</strong>      <button
        disabled={!available}
        onClick={() => onOpenTicket(market.marketId, "SELL")}
      >
        Sell · {quote?.bid ?? "—"}
      </button>      <button
        disabled={!available}
        onClick={() => onOpenTicket(market.marketId, "BUY")}
      >
        Buy · {quote?.ask ?? "—"}
      </button>      {snapshot.state !== "fresh" && (
        <span>Prices updating</span>
      )}
    </article>
  );
});

A quote change on Market A notifies Market A’s reader only—it does not replace the parent list’s whole markets array. Buttons open a ticket; they do not silently place an order.

The ticket declares its own demand:

function OrderTicket({ marketId }: { marketId: string }) {
  useMarketDemand([marketId], PRIORITY.orderTicket);
  const snapshot = useQuote(marketId);  // Render quantity, side, current quote, review and submit controls.
  // ...
}

Scrolling the grid does not cancel the ticket’s claim. Dismissing the ticket removes only that owner; a visible row or nearby overscan entry might still require the identical instrument.

Recheck the live quote immediately before buy or sell

What the row last painted can trail the authoritative store by a moment. The submit path therefore reads again:

async function submitOrder(intent: {
  clientOrderId: string; // Stable for retries of this exact intent.
  marketId: string;
  side: "BUY" | "SELL";
  quantity: string;
  reviewedVersion: number;
}) {
  const snapshot = quoteStore.readLatest(intent.marketId);
  const quote = snapshot.quote;
  if (
    !navigator.onLine ||
    snapshot.state !== "fresh" ||
    !quote ||
    !quote.tradable ||
    Date.now() >= quote.staleAt
  ) {
    throw new Error("Wait for a fresh, tradable quote.");
  }  if (quote.version !== intent.reviewedVersion) {
    throw new Error("The quote changed. Review it again.");
  }  const limitPrice =
    intent.side === "BUY" ? quote.ask : quote.bid;  const response = await fetch("/api/v1/orders", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      ...intent,
      type: "LIMIT",
      limitPrice,
    }),
  });  return parseOrderResponse(response);
}

Responses are parsed for acceptance, rejection, or ambiguity. A retry of one intent reuses its original client order identifier. Authorization, quote verification, acceptance, and fills stay server-side decisions. Limit prices only constrain what executions are allowed—they never guarantee a fill.

The two timing decisions

Decision Purpose
~150 ms subscription window Coalesce rapid viewport churn before replacing the connection
~100 ms visual publication interval Cap ordinary price re-render frequency

Inbound messages still update the immediate store immediately. Availability flips skip the ordinary visual delay. The 150 ms window is not meant to slow scrolling, clicks, or prices already arriving on the active connection.

Memory and cleanup

A catalogue cache (for example React Query) needs its own retention rules. A virtualized list might mount ten rows while still caching thousands of loaded records. Keep the jobs distinct: virtualization owns mounted UI, the planner owns subscription capacity, the store owns quote state, and React paints the snapshots that matter.