cf.completefrontend
My progress
SYSTEM DESIGN · #75

Design a live stock price ticker

Live quotes on a watchlist and a symbol page: rows of prices that change many times per second, flash green or red on each move, and feed a live chart. The hard parts are market data itself (quotes, trades, sequence numbers, snapshots, market sessions, delayed feeds), one socket that follows what is on screen, rendering hundreds of updates per second without re-rendering the table, flashes that stay safe, numbers that don't jitter, and honest labels when data is stale, delayed or the market is closed.

The question

Design the live price experience for a brokerage web app. A user has a watchlist of up to a few hundred symbols, and a symbol page with a big live price and an intraday chart. Prices should update in real time, flash when they move, and stay correct.

Walk me through the data you receive, how it reaches the browser, how you render many updates per second without freezing the page, how you show staleness, delayed data and closed markets, and how a screen reader user follows a price.

Real-life examples: Robinhood web watchlist, Yahoo Finance quote pages, Google Finance, TradingView watchlists and charts, Zerodha Kite market watch, Coinbase price pages

Requirements: which ticker are we building?

A stock ticker here means the live price parts of a trading or finance app: a watchlist (a table of symbols you follow, each row with a price and a change) and a symbol page (one big price, a few stats, and a live chart). You have seen them on Robinhood, Yahoo Finance, Google Finance, TradingView, Zerodha Kite and Coinbase. Numbers change many times per second, cells flash green or red, and the chart line grows at its right edge.

Some neighbours already cover pieces of this, and we link them instead of repeating them. The live-comments guide covers batching a fast stream into one render per animation frame. The analytics-dashboard guide covers chart rendering and downsampling. The discord guide covers subscribing only to the part of a list you can see.

The websockets and server-sent-events lessons cover the transports themselves. This guide focuses on what is special about market data: prices that replace each other, rules set by exchanges, and states such as delayed, halted and closed.

Start with questions. Each answer changes the design. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
Which markets?US stocks and ETFs, plus crypto that trades 24/7Market sessions, closed states, decimals per instrument
Real-time or delayed?Real-time for users with a market data entitlement, 15-minute delayed for othersEvery price needs a delayed label and the server must know the user's entitlement
How many symbols on screen?Watchlists up to 300 symbols, about 20 to 30 rows visible at onceSubscribe to visible rows only, not the whole list
How fast do prices change?Busy symbols like SPY or NVDA can tick hundreds of times per second in busy momentsConflation and per-frame rendering; we can't paint every tick
Is it for trading or for reading?Reading and deciding; the order ticket is a separate featureWe show last price and bid/ask, not the full order book
Which devices?Desktop and mobile web; mid-range Android phones matterA tight main-thread budget and battery care
Alerts?Yes: "tell me when AAPL crosses 200", even when the app is closedAlerts are evaluated on the server and sent as push notifications

Functional requirements

  • Watchlist rows show symbol, last price, change, percent change and a small sparkline; they update live.
  • A symbol page shows the last price, bid and ask, day range, volume, and an intraday chart that grows live.
  • A price cell flashes green on an up move and red on a down move, with an arrow and sign so color is not the only signal.
  • Every price shows its state: live, delayed, stale, pre-market, after-hours, halted or closed.
  • Users can create price alerts that fire even when the tab is closed.
  • A pause control stops live updates for users who need the screen to hold still.

Non-functional requirements

  • Latency: on a good connection, a price change reaches the screen within the view's update interval plus about 100 ms at the 95th percentile: about 200 ms on the symbol page and 350 ms on the watchlist with the intervals chosen later.
  • Smoothness: the page stays at 60 frames per second while 300 symbols update; no long tasks over 50 ms from ticks.
  • Correctness: never show a price older than a newer one we already showed (no going back in time after a reconnect).
  • Honesty: if data is stale or delayed, the user can see it at a glance.
  • Battery and data: background tabs and phones must not burn CPU on prices no one is looking at.
  • Accessibility: WCAG 2.2 AA, including the rules on flashing and on auto-updating content.

Out of scope

  • Placing orders and the order book depth ladder (a trading feature of its own).
  • The exchange connectivity and matching engine; we start where our market data servers already have normalized ticks.
  • Full charting tools such as indicators and drawings; we cover the live intraday line and candles.
Wireframe of the ticker screen. A header has a status pill reading Live, a Pause button, and the text US market open, 14:32 ET. On the left, a watchlist panel shows four rows: AAPL 201.15 up 1.4 percent, flashing; NVDA 142.08 down 0.8 percent, flashing; TSLA 248.50 greyed out as stale; and ABC 12.40 with a Halted badge. A note says 300 symbols, 25 visible. On the right, the AAPL symbol page shows a big price of 201.15, up 2.75 or 1.39 percent, a Read price button, an intraday chart with a dashed previous close line, and a Set alert button.Watchlist and symbol page● LivePauseUS market open, 14:32 ETAAPL201.15▲ 1.4%NVDA142.08▼ 0.8%TSLA248.50staleABC12.40Halted300 symbols, 25 visible201.15Read price▲ +2.75 (+1.39%)Set alertdashed: prev close
Every price shows its state: live and flashing, stale and greyed, or halted on purpose. The header carries the connection status and the pause control; the symbol page adds the chart, an on-demand announcement and alerts.

Check your understanding · Exercise 1

Which answers to the scoping questions change the frontend design the most? Select all that apply.

What makes a live ticker hard

A price ticker looks like a table with numbers. Five properties of market data make it harder than it looks, and every later chapter answers one of them.

  1. Fast and bursty. A quiet stock may not trade for a minute. A busy one can produce hundreds of quote changes in one second right after a news headline or at the 9:30 open. Your design must survive the burst, not the average.
  2. Faster than eyes and screens. A 60 Hz screen paints 60 frames per second. A person can't read a number that changes more than a few times per second. So most ticks will never be seen, and the question becomes which ones to drop and where.
  3. Ordered and stateful. A quote is the latest state of a symbol. If an old message arrives after a newer one, or a message is lost during a reconnect, the screen shows a wrong price with full confidence. Order and gaps matter.
  4. Shaped by rules, not only by code. Exchanges and regulators set trading hours, halts, minimum price steps (tick sizes) and who may see real-time data. A user without a real-time data license may only see delayed prices, and the UI must say so.
  5. Emotional and visual. People act on money they see. A flash that fires 20 times per second is unreadable and can be unsafe for people with photosensitive epilepsy; a price that silently froze can cost someone money.

A useful number to hold in your head for the rest of the guide: 300 symbols in a watchlist, 25 of them visible, an average of 5 updates per second per symbol in normal trading and bursts of 50 or more for the busiest names. That is 1,500 updates per second for the whole list on average, and around 125 per second for the visible rows. A naive design pays for all 1,500. A good one pays for at most 25 cell updates per frame.

Think first

If most ticks will never be seen, why not just poll a REST endpoint every 2 seconds?

The data: quotes, trades, sequence numbers and sessions

Before choosing a transport, know what flows through it. Market data comes in three main kinds, and they behave very differently on the client.

KindWhat it isExampleCan you drop old ones?
Quote (top of book)The best price buyers offer (bid) and sellers ask (ask), with sizes, plus the last trade priceAAPL bid 201.14 x 300, ask 201.16 x 200, last 201.15Yes: only the newest quote matters
Trade (print)One execution: price, size, time100 shares at 201.15 at 14:32:05.123No, if you show a trade list or count volume; each one is an event
Order book (depth)Every price level with the size waiting there10 levels of bids and 10 of asksNo for deltas: a missed change corrupts the book

For US stocks, the best bid and the best ask across all exchanges together form the NBBO (National Best Bid and Offer), which consolidated feeds publish. A single exchange's feed shows only that exchange's best prices, so its quote can differ slightly from the NBBO. The watchlist and the big price on the symbol page only need quote state: last, bid, ask, day volume, and the reference price used for change.

Change is last - prevClose, percent change is change / prevClose, and the spread is ask - bid. Because a quote is state, not an event, the newest one simply replaces the old one. That single fact makes the whole design simpler, and the next chapters lean on it.

Three kinds of market data side by side. A quote is state where the newest wins: bid 201.14 for 300, ask 201.16 for 200, last 201.15; dropping old ones is safe. A trade is an event to keep: 100 at 201.15 at 14:32:05.123, then 200 at 201.16; dropping one gives wrong volume. An order book is deltas on a snapshot: the size at 201.16 changes to 500, 201.17 gets 900, 201.13 is removed; a gap means a fresh snapshot.Quotestate: newest winsTradeevent: keep each oneOrder bookdeltas on a snapshotbid 201.14 x 300ask 201.16 x 200last 201.15100 @ 201.15at 14:32:05.123200 @ 201.16201.16: size 500201.17: new, 900201.13: removeddrop old ones: safedrop one: wrong volumegap: new snapshota watchlist row needs only the quote
The watchlist and the big price only need quotes, and a quote replaces the one before it. That is what makes it safe to drop updates later. Trades and book deltas are events: miss one and the state is wrong.

A quote message

ts
type QuoteUpdate = {
  type: "quote";
  symbol: string;       // "AAPL"
  seq: number;          // per-symbol sequence, grows by 1 per change
  ts: number;           // exchange time, ms since epoch
  last: string;         // "201.15": decimal string, not a float
  lastSize: number;
  bid: string;
  bidSize: number;
  ask: string;
  askSize: number;
  volume: number;       // shares traded today so far
  session: "pre" | "regular" | "post" | "closed" | "halted";
};
One quote update on the wire. It carries the full top-of-book state, so applying the newest one is always enough.

Prices travel as decimal strings, as Coinbase's public feeds do, or as integers in the instrument's smallest unit. A JavaScript number can't hold 0.1 exactly, and a crypto price with 8 decimals plus a large integer part gets close to the limit of 15 to 17 significant digits. For display a float is usually fine; for anything that compares or sums prices, keep the exact form and convert only when you format.

Sequence numbers, snapshots and deltas

Each symbol has its own sequence number (seq) that grows by one with every change. The client stores the last seq it applied per symbol. Three rules follow:

  • seq lower than or equal to the stored one: an old or duplicate message, for example from before a reconnect. Drop it.
  • seq exactly one higher: apply it.
  • seq more than one higher: a gap. For full-state quotes, apply it anyway, because the newest quote is complete. For trades or order book deltas, the state is now wrong: ask the server for a fresh snapshot.

A snapshot is the full current state of a symbol at a given seq, including fields that rarely change: prevClose, day high and low, tick size, decimals, session, and whether this user's data is delayed. After subscribing, the server sends the snapshot first and then deltas (updates) with higher seq. Updates that arrive before the snapshot are buffered and replayed only if their seq is newer than the snapshot's.

Six messages for AAPL in arrival order. Quote 499 arrives before the snapshot, is buffered, then dropped. Quote 501 is buffered, then applied after the snapshot. The snapshot at seq 500 is applied. Quote 502 is applied and the stored seq becomes 502. A duplicate quote 502 is dropped. Quote 507 is a gap: a full quote is applied, but an order book delta would need a new snapshot.arrival order on the socketq 499bufferthen dropq 501bufferthen applysnap 500applyseq = 500q 502applyseq = 502q 502duplicatedropq 507gapquote: applydelta: resnapbefore the snapshot: keep only seq above 500after: drop seq at or below stored, apply the rest
The stored seq decides everything: older or equal is dropped, newer is applied. A gap is harmless for full quotes and fatal for deltas.

Market hours, halts and delayed data

StateWhen (US stocks, Eastern Time)What the UI shows
Pre-marketCommonly from 4:00 to 9:30Price with a "Pre-market" label; change vs previous close; thinner volume
Regular9:30 to 16:00Live price, no extra label
After-hoursCommonly 16:00 to 20:00The 16:00 closing price plus a separate after-hours price and change, as many finance sites show it
ClosedNights, weekends, holidaysLast price with "At close" and the close time; no flashes; no stale warning
HaltedAn exchange pauses trading (news pending, or a volatility pause)A "Halted" badge; the price is frozen on purpose and must not look stale or live
24/7CryptoAlways live; "24h change" instead of change vs a close

Delayed data is a licensing matter, not a performance choice. Exchanges sell real-time data under license, often priced per user, while data delayed by a set time (commonly 15 minutes for US stocks) is much cheaper or free to show. So a user without a real-time entitlement sees AAPL as it was 15 minutes ago. The server decides the entitlement and puts delayedBy: 900 (seconds) in the snapshot.

Exchange display policies generally require delayed prices to be clearly labelled as delayed. So the UI shows a label ("Delayed 15 min") and the exchange timestamp, and never flashes a delayed price as if it were live news. Mixing a delayed price on the watchlist with a real-time price on the symbol page, without labels, is a serious bug: the user can't tell which number to trust.

Check your understanding · Exercise 2

The client stored seq 500 for AAPL. Which statements are true? Select all that apply.

Transport and staleness: one socket, honest prices

The naive version opens one connection per symbol, or polls GET /quotes?symbols=... every second. With 300 symbols, one connection each means 300 sockets: browsers limit how many sockets a page may open, each one costs memory and a TLS handshake, and reconnecting 300 sockets after a network blip is a storm. Polling wastes requests on unchanged symbols and adds up to a full interval of delay.

Decision How do prices reach the browser?

Polling a quotes endpoint

Every 1 to 5 seconds, GET /quotes?symbols=AAPL,MSFT,... returns the latest quote for each.

Pros

  • Simplest; works through any proxy; plays well with HTTP caching for delayed data.

Cons

  • Average delay of half an interval plus a round trip.
  • Sends unchanged data; no order between responses; costly at scale.

Server-Sent Events (SSE)

One EventSource stream per tab; the subscription set is in the URL or changed with separate POST /subscriptions calls tied to a stream id.

Pros

  • One-way push is all a ticker needs; plain HTTP; automatic reconnect with Last-Event-ID.

Cons

  • Changing subscriptions needs a second channel and a way to tie it to the stream.
  • Text only, so no compact binary frames; on HTTP/1.1 it uses one of about 6 connections per host.

One multiplexed WebSocket

One socket per tab carries subscribe and unsubscribe messages up and quote messages for many symbols down.

Pros

  • Subscriptions change on the same channel, in order with the data.
  • Binary or compact JSON frames; one handshake; one reconnect.

Cons

  • We build reconnect, heartbeats and resubscribe ourselves.
  • Some corporate proxies break WebSockets, so keep a fallback.

What we pick: One multiplexed WebSocket per tab, the model of public streaming APIs such as Zerodha Kite's and those of the big crypto exchanges. Subscriptions change constantly as the user scrolls and navigates, and keeping them on the same ordered channel as the data avoids races between "unsubscribe" and an in-flight quote. SSE is a fine fallback for a read-only page with a fixed set of symbols, and polling is the last fallback for delayed data. The websockets and server-sent-events lessons cover the transports themselves.

Subscribe to what is on screen

A watchlist of 300 symbols shows about 25 rows. Subscribing to all 300 costs the server, the network and the client's parser for 275 rows nobody sees. Instead, each row reports whether it is visible with an IntersectionObserver (with a rootMargin of a few rows so rows just off-screen are ready), and a subscription manager turns that into messages. This is the same idea as the discord guide's member list, where the client subscribes to the range it can see.

  • Reference counting. AAPL may be wanted by a watchlist row, the symbol page header and a mini ticker bar at once. The manager counts holders per symbol and sends subscribe on the first and unsubscribe on the last.
  • Linger before unsubscribing. When a count drops to zero, wait about 5 seconds before sending unsubscribe. Scrolling back and forth then doesn't flap, and we keep the latest price ready.
  • Batch the changes. Collect changes for one animation frame or 100 ms and send one message with many symbols, so a fast fling sends a few messages, not hundreds.
  • Rows without a subscription still show something. Off-screen rows keep their last known price from the snapshot; when they scroll in, the fresh snapshot replaces it within a round trip.
json
{ "op": "subscribe", "id": 42,
  "symbols": ["AAPL", "NVDA", "SPY"],
  "fields": "quote",
  "maxRateMs": 250 }

{ "op": "unsubscribe", "id": 43, "symbols": ["TSLA"] }

{ "type": "snapshot", "symbol": "AAPL", "seq": 1048213,
  "last": "201.15", "prevClose": "198.40", "bid": "201.14",
  "ask": "201.16", "volume": 41230115, "session": "regular",
  "delayedBy": 0, "tickSize": "0.01", "decimals": 2 }
Client to server: one message changes many subscriptions and asks for a rate. The server answers each new symbol with a snapshot.
Sequence diagram of a scroll. The viewport tells the subscription manager that rows 26 to 50 entered and rows 1 to 25 left. The manager sends one subscribe message for 25 symbols to the gateway. The gateway sends snapshots with their seq to the quote store, then quotes every 250 milliseconds. After a 5 second linger, the manager sends one unsubscribe for the old 25. The store notifies the rows, which render once per frame.ViewportSub managerGatewayQuote storerows 26-50 enterrows 1-25 leavesubscribe 25snapshots, seqquotes, 250 msunsubscribe old 25render per frame
One scroll becomes two messages, not fifty. Snapshots fill the new rows within a round trip, and the old rows stay subscribed for a few seconds in case the user scrolls back.

Conflation: send the latest, not every tick

Conflation means merging many updates into one: for each subscribed symbol, the server keeps only the newest quote and sends it at most once per interval. If NVDA changes 80 times in 250 ms, the client gets one message with the newest state instead of 80. Because quotes are full state, nothing is lost that the user could have seen.

ApproachMessages per second for 25 visible symbols in a burst (50 changes per second each)Good for
Every tick1,250Trading screens that show time and sales; professional tools
Conflate at 100 msat most 250The symbol page's big price and chart
Conflate at 250 msat most 100Watchlist rows
Conflate at 1,000 msat most 25Background tabs, mobile on a weak network, delayed data

The client asks for the rate (maxRateMs) per subscription, so the watchlist and the symbol page can differ, and so can a phone on a slow network. Many public market data APIs offer the same choice; crypto exchanges, for example, publish order book streams at more than one update speed. The client still batches per frame (next chapter), because even 100 messages per second can land several in one frame.

Reconnect with a fresh snapshot

Networks drop. When the socket closes, the client marks every symbol as disconnected (the stale style described below), retries with exponential backoff and jitter, and resubscribes to its current set. For example, wait 0.5 s, 1 s, 2 s, 4 s and so on up to 30 s, each with a random spread so a million clients don't reconnect in the same second.

The server answers the resubscribe with fresh snapshots. The client applies a snapshot only if its seq is newer than what it has, then clears the disconnected marks. We never replay missed quotes. The discord guide resumes by replaying every event after the last sequence number, because chat messages are events; quotes are state, so the newest snapshot is enough.

A heartbeat catches the silent half-open sockets that mobile networks often leave behind: the client sends a ping every 15 seconds and closes the socket if no pong arrives within 5.

Sequence diagram of a reconnect. The socket gets no pong within 5 seconds from the gateway, so it tells the UI that rows go stale. A retry after 0.5 seconds fails. A retry after 1 second connects. The socket subscribes to the visible symbols, the gateway sends snapshots with newer seq numbers, and the UI goes live again with one render.UISocketGatewayno pong in 5 srows go staleretry at 0.5 s: failsretry at 1 s: opensubscribe visiblesnapshots, newer seqlive again
The failure path is visible to the user at once: rows go stale the moment the heartbeat fails, not after a per-symbol timeout. Recovery never replays old quotes; snapshots with a newer seq are enough.

Check your understanding · Exercise 3

A user flings a 300-row watchlist from top to bottom in one second. What should the subscription manager do? Select all that apply.

Stale data, connection status and closed markets

The worst bug in a ticker is not a slow price. It is a price that froze while looking live. The socket died in a tunnel, the server's feed for one exchange stalled, or the laptop woke from sleep with a socket that thinks it is still open. The numbers stay on screen, crisp and green, and the user trades on a price from 10 minutes ago.

The naive fix is a timer per symbol: "no update for 10 seconds, grey it out". It fails both ways. A thinly traded stock can go a minute without a single change while the feed is perfectly healthy, so it is marked stale for no reason. And during the 10 seconds before the timer fires, a dead connection still looks live.

Decision How does the client decide that a price is stale?

Per-symbol timeout

Each symbol stores receivedAt; a price older than N seconds is shown as stale.

Pros

  • Simple; needs nothing from the server.

Cons

  • Quiet symbols look broken; busy ones take N seconds to look broken.
  • Can't tell "nothing happened" from "we heard nothing".

Connection health plus server feed status

A heartbeat every 15 seconds proves the socket is alive.

The server sends a status message when a feed or exchange is delayed, recovered, halted or closed.

Pros

  • Separates a quiet market from a broken pipe.
  • Reacts within one heartbeat interval to a dead socket.

Cons

  • Needs the server to report feed health honestly.

Both, with the timestamp always visible

Connection and feed status decide the stale state; each row also shows its last update time on hover or in the details row.

Pros

  • Users can judge for themselves; support teams can see what the user saw.

Cons

  • A little more UI to design.

What we pick: Combine connection health and server feed status as the source of truth, and always make the last update time available. A quiet symbol on a healthy connection is shown as live with its time; any symbol on a dead or reconnecting connection is shown as stale at once, even if it updated a second ago.

The states a price can be in

StateTriggerWhat the row shows
LiveConnected, feed healthy, market openNormal price, flashes on
DelayedSnapshot has delayedBy above 0"Delayed 15 min" badge; exchange time shown; flashes off or muted
StaleSocket reconnecting, heartbeat missed, or feed status delayedPrice kept but greyed, a clock icon, "as of 14:32:05"; flashes off
HaltedStatus message from the server"Halted" badge; not greyed as stale, because the frozen price is correct
ClosedSession closed"At close" with the close time; no stale warning
PausedThe user pressed pause"Paused" pill; values held until resume

Never blank a price when it goes stale. An empty cell hides information the user may still need, and the layout jumps. Keep the last value, change its style, and say how old it is. When the connection comes back, the fresh snapshot replaces it and the style returns to live in the same frame.

The connection itself gets one status pill in the header: a dot and a word, "Live", "Reconnecting", "Offline", plus "Delayed 15 min" or "Market closed" when they apply to the whole view. The pill is a small state machine, and modelling it as one stops impossible combinations such as "Live" while the retry timer is running.

Connection state machine. Connecting goes to Live when the socket opens and snapshots arrive. Live goes to Reconnecting when a pong is missed or the socket closes. Reconnecting retries with backoff and goes back to Live when snapshots arrive, or to Offline on the browser's offline event. Offline goes back to Connecting on the online event. A note says pause is separate: it freezes the UI, not the socket, and every row reads this state for its stale style.Connectingfirst snapshotsLiverows liveReconnectingbackoff + jitterOfflinerows staleopenno pongsnapshotsoffline eventonline eventpaused is separate: it freezes the UI, not the socketevery row reads this state for its stale style
Modelling the pill as a state machine rules out impossible mixes like Live with a retry timer running. Rows are stale in Reconnecting and Offline, whatever their own last update time says.

Laptops that sleep deserve a special case. Timers don't fire during sleep, so on wake the socket may look open while the server dropped it minutes ago. Listen for visibilitychange and the online event, and on either one send a ping right away; if no pong arrives within a few seconds, treat the socket as dead and reconnect instead of waiting for the next scheduled heartbeat.

Check your understanding · Exercise 4

Which rows should show the stale style (grey, clock icon, "as of" time)? Select all that apply.

Rendering and formatting hundreds of updates per second

The naive React version keeps all quotes in one state object at the top of the watchlist and calls setQuotes(q => ({ ...q, [msg.symbol]: msg })) for every message. Each call re-renders the table component, which re-renders 25 visible rows (or 300 if the list is not virtualized), each with 6 cells and a sparkline. At 100 messages per second that is 100 renders per second, and React 18 batching helps only when several messages land in the same task.

Estimate the cost: on a mid-range phone, one render of the table can plausibly take 4 to 8 ms. A hundred of them use most of every second, and scrolling and typing in the search box start to stutter.

Two facts reframe the problem. First, the screen paints at most once per frame: about 60 times per second on most screens, 120 on fast ones, so a value that changes twice inside one frame was never seen in its first form. Second, a person can't read a price that changes more than a few times per second anyway. So the goal is not to render every update, but to render at most once per frame, and only the cells that changed.

Decision Where do live quotes live, and how do they reach the screen?

React state at the top

One useState or context with every quote; each message calls setState.

Pros

  • Simplest code; React DevTools shows everything.

Cons

  • Every update re-renders the whole table, or forces heavy memo everywhere.
  • Context changes re-render every consumer, even rows whose symbol didn't change.

External store, one flush per frame, per-symbol subscriptions

Messages write into a plain Map outside React and mark the symbol dirty.

One requestAnimationFrame callback notifies only the listeners of dirty symbols.

Each row reads its own symbol with useSyncExternalStore.

Pros

  • At most one render pass per frame, touching only changed rows.
  • Still declarative React; works with concurrent rendering without tearing.

Cons

  • A store and a hook to write and test.
  • Each changed row still goes through React's render and diff.

Direct DOM writes for the hot cells

Rows render once with React; the price cell registers its element with the store.

The frame flush sets textContent and restarts the flash animation directly.

Pros

  • The cheapest possible update: a text change on one node.
  • React never re-renders for a tick.

Cons

  • Two sources of truth if done carelessly; React must not also render those values.
  • Harder to test; easy to leak elements that unmounted.

What we pick: Use the external store with one flush per frame and per-symbol useSyncExternalStore as the default: it keeps React declarative and cuts renders to the visible rows that changed, at most 60 times per second. For the very hottest cells (the big price on the symbol page, or a 300-row pro watchlist), add direct DOM writes behind the same store. Keeping quotes out of React state is the global-vs-local-state lesson in action: high-frequency server data is neither local UI state nor something every component should re-render for.

Five messages arrive within one 16.7 millisecond frame: AAPL, NVDA, AAPL, AAPL, MSFT. They go into the quote store, a Map with the latest quote per symbol and a dirty set of AAPL, NVDA and MSFT. One requestAnimationFrame flush notifies only those three rows, which render once each. The other 22 visible rows don't render, and the two older AAPL values are never painted.5 messages arrive within one 16.7 ms frameAAPLNVDAAAPLAAPLMSFTQuote storelatest per symbol; dirty: AAPL, NVDA, MSFTrAF flushonce per frameAAPL rowNVDA rowMSFT row22 other rows3 rows render; 2 older AAPL values are never painted
Writes are cheap and happen at message speed; renders happen at frame speed. The first two AAPL values were replaced before any frame could show them, so dropping them loses nothing.
ts
const quotes = new Map<string, Quote>();
const listeners = new Map<string, Set<() => void>>();
const dirty = new Set<string>();
let frame = 0;

export function applyQuote(q: Quote) {
  const prev = quotes.get(q.symbol);
  if (prev && q.seq <= prev.seq) return; // old or duplicate
  quotes.set(q.symbol, q);               // new object, so React sees a change
  dirty.add(q.symbol);
  frame ||= requestAnimationFrame(flush);
}

function flush() {
  frame = 0;
  for (const s of dirty) listeners.get(s)?.forEach((fn) => fn());
  dirty.clear();
}

export function subscribe(symbol: string, fn: () => void) {
  if (!listeners.has(symbol)) listeners.set(symbol, new Set());
  listeners.get(symbol)!.add(fn);
  return () => listeners.get(symbol)!.delete(fn);
}

export const getQuote = (symbol: string) => quotes.get(symbol);
The quote store: writes are cheap, and listeners hear about each changed symbol at most once per frame.
tsx
function useQuote(symbol: string) {
  const sub = useCallback((cb: () => void) => subscribe(symbol, cb), [symbol]);
  const get = () => getQuote(symbol);
  return useSyncExternalStore(sub, get, get); // third argument: the snapshot for server rendering
}

const QuoteRow = memo(function QuoteRow({ symbol }: { symbol: string }) {
  const q = useQuote(symbol);
  if (!q) return <RowSkeleton symbol={symbol} />;
  return (
    <tr>
      <th scope="row">{symbol}</th>
      <PriceCell value={q.last} decimals={q.decimals} />
      <ChangeCell last={q.last} prevClose={q.prevClose} />
    </tr>
  );
});
Each row subscribes to one symbol. A tick for NVDA never re-renders the AAPL row. The subscribe function is memoized, because a new one on every render makes React resubscribe.

Note what the store does not do: it does not keep every intermediate quote. If AAPL changes 4 times inside one frame, the store holds only the newest, and the row renders once. Those 3 dropped values were never visible, so nothing is lost. The live-comments guide uses the same per-frame batching for a stream of messages; the difference here is that quotes replace each other, while comments append, so we can drop instead of queue.

Two more pieces finish the picture. The watchlist is virtualized past about 100 rows (the virtualized-list guide), so the DOM holds only the visible rows plus a small buffer. And at extreme rates, parsing itself becomes the cost: thousands of JSON.parse calls per second can take several milliseconds.

A Web Worker can then own the socket, parse, keep the newest quote per symbol, and post one batch per frame to the main thread. That only pays off at high rates, since every postMessage copies its data. With server conflation at 250 ms, the main thread is usually fine.

Try it in the lab below: one second of ticks for 50 symbols, with rough cost estimates (on a laptop, 0.06 ms per row render and 0.4 ms per React commit; a phone is about 4 times slower). Per-tick rendering re-renders all 50 rows for every message, so at 500 ticks per second it needs more than a second of work per second and the backlog grows.

The frame flush keeps work small, and a change reaches the screen within one or two frames (about 16 to 35 ms in the lab). Server conflation cuts work further but adds up to about 250 ms of delay, which is why the symbol page asks for a faster rate than the watchlist.

Ticks per second
Strategy
Device
One second of 500 ticks for 50 symbols with setState per tick on a laptop. 500 price changes for subscribed symbols became 500 messages to the browser, causing 500 React commits and 25000 row renders. Main-thread work: 1705 ms. Of 60 frames, 0 painted changes and 60 were missed. 0 values were painted and 500 were never shown. Worst delay: 709 ms.60 frames in one second: painted, idle, or missed (main thread busy)ticks 500, messages in 500React commits: 500row renders: 25,000work: 1,705 ms per secondframes missed: 60 of 60worst delay: 709 msvalues painted: 0; never shown: 500 (replaced before they could be seen)
This second needs 1,705 ms of main-thread work, more than a second: the backlog grows, scrolling and typing freeze, and prices reach the screen up to 709 ms late.

Check your understanding · Exercise 5

With the per-frame store, AAPL receives 5 quotes within one 16 ms frame. What happens? Select all that apply.

Formatting numbers that move

A number that changes 4 times per second is read differently from a number on a static page. If its width changes, the whole column wobbles, and if its decimals change, 201.1 and 201.10 look like different prices. If formatting is slow, it shows up in every frame. Three rules cover most of it.

Decimals come from the instrument, not the value

Every instrument has a tick size: the smallest step its price can move. The server sends it in the snapshot, along with how many decimals to show. Never guess decimals from the value: 201.1 must show as 201.10 because AAPL quotes in cents.

InstrumentTypical stepShown as
US stock at or above $1$0.01 under SEC Rule 612; amendments adopted in 2024 add a $0.005 step for some heavily traded stocks, with compliance postponed to November 2027201.15
US stock under $1Down to $0.00010.4321
Bitcoin in US dollars$0.0167,412.35
A low-priced crypto tokenMany decimal places0.00001234
A crypto quantityUp to 8 decimals for bitcoin (1 satoshi is 0.00000001 BTC)0.01450000 BTC

Currency comes from the instrument too: a Tokyo-listed stock is in yen with no decimals, a Frankfurt one in euros with a comma as the decimal separator in a German locale. London shares are often quoted in pence (written GBX or GBp), which is not an ISO 4217 currency code. Intl has no symbol or rules for it and would print GBX 123.40, so format the number and add the p yourself. The locale-formatting lesson covers Intl in depth.

Fixed width: tabular numbers

In most fonts the digit 1 is narrower than 8. When 201.11 becomes 208.88, the text gets wider and a right-aligned column shifts by a few pixels on every tick; across 25 rows the table seems to shiver. The CSS font-variant-numeric: tabular-nums switches to equal-width digits when the font has them (most system and UI fonts do). Combine it with right alignment and fixed decimals, and only the changed digits move.

Two columns of the same three prices, 201.11, 208.88 and 201.17. On the left, with proportional digits, each value has a different width, so the right-aligned column shifts on every tick. On the right, with tabular-nums and fixed decimals, every value has the same width and only the changed digits move.proportional digits201.11208.88201.17width changes, column wobblestabular-nums201.11208.88201.17same width, only digits change
The same prices, two font settings. font-variant-numeric: tabular-nums gives every digit the same width, so a column of fast-changing prices stops shivering.

Cache the formatters

new Intl.NumberFormat(...) loads locale data and is many times slower than calling format on an existing instance. Creating one per cell per frame turns formatting into a measurable cost. Create each formatter once and keep it in a Map keyed by its options.

ts
const cache = new Map<string, Intl.NumberFormat>();

function nf(locale: string, opts: Intl.NumberFormatOptions) {
  const key = locale + JSON.stringify(opts);
  let f = cache.get(key);
  if (!f) cache.set(key, (f = new Intl.NumberFormat(locale, opts)));
  return f;
}

const price = (v: number, d: number, loc = "en-US") =>
  nf(loc, { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);

const change = (v: number, d: number, loc = "en-US") =>
  nf(loc, { minimumFractionDigits: d, maximumFractionDigits: d, signDisplay: "exceptZero" }).format(v);

const pct = (v: number, loc = "en-US") =>
  nf(loc, { style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2, signDisplay: "exceptZero" }).format(v);

const volume = (v: number, loc = "en-US") =>
  nf(loc, { notation: "compact", maximumFractionDigits: 1 }).format(v);

price(201.1, 2);     // "201.10"
change(2.75, 2);     // "+2.75"
pct(0.01386);        // "+1.39%"
volume(41230115);    // "41.2M" (in de-DE: "41,2 Mio.")
One cached formatter per locale, decimals and style. signDisplay gives the plus sign for free.

Compact volume (41.2M) is right for a watchlist, where it only needs to give a sense of size. The symbol page can show the full number in a tooltip or a details row. Avoid compact notation for prices: 1.2K for a price of 1,234.56 hides exactly the digits that move.

Check your understanding · Exercise 6

Which formatting choices keep a fast-moving price column readable? Select all that apply.

The flash on change, safely

The green or red flash tells you, out of the corner of your eye, that something moved and in which direction. It is the most loved and the most dangerous detail of a ticker. Three things go wrong with the naive version: the flash doesn't restart, it fires too often, and it relies on color alone.

Restarting the animation on every change

The naive code adds a class flash-up when the price rises. The first time it works. The second up tick does nothing: the class is already there, so the CSS animation doesn't run again. Removing the class in a timeout is fragile, and changing the React key to remount the cell rebuilds DOM for every tick.

Decision How do we restart the flash on each change?

Class toggle with a forced reflow

Remove the class, read el.offsetWidth to force style and layout, add the class again.

Pros

  • Pure CSS keyframes; works everywhere.

Cons

  • The forced read causes synchronous layout; doing it for 25 cells in one frame is wasteful.

Web Animations API

Call el.animate(keyframes, { duration: 600 }); cancel the previous animation first.

Pros

  • Restarts on every call with no layout read; easy to cancel and to skip.
  • Supported in all current browsers.

Cons

  • Keyframes live in JavaScript, so the theme colors must be passed in from the theme or a user setting.

Remount with a new key

Render the cell with key={seq} so React creates a new element and the CSS animation starts fresh.

Pros

  • No imperative code.

Cons

  • Creates and destroys DOM per tick; loses focus and selection inside the cell.

What we pick: Use the Web Animations API from the price cell. It restarts cleanly with no forced layout, and it gives us a handle to skip or cancel flashes, which we need for throttling and reduced motion.

Throttling flashes

A busy symbol can change 20 times per second, and flashing on each change makes a strobing cell. That is unreadable, and it can be unsafe. WCAG 2.3.1 (Three Flashes or Below Threshold, level A) says nothing may flash more than three times in any one-second period, unless the flashes stay below the general and red flash thresholds (for example, because the flashing area is small).

Measuring flash areas for every screen size is impractical, so set a simple rule that always passes: at most one flash per cell every 500 ms, which is 2 per second at worst. Between flashes the number still updates; only the background animation is skipped. Keep the flash soft too: a tinted background that fades out over 600 ms, never a hard on-off blink, and never saturated red.

tsx
const MIN_FLASH_GAP_MS = 500;
const FLASH = { up: "rgb(0 150 80 / 0.25)", down: "rgb(210 40 40 / 0.25)" }; // swapped by a user setting

function PriceCell({ value, decimals }: { value: string; decimals: number }) {
  const ref = useRef<HTMLTableCellElement>(null);
  const prev = useRef(value);
  const lastFlash = useRef(0);
  const reduced = useReducedMotion(); // OS setting or the app's "Flash prices" toggle
  const dir = Number(value) > Number(prev.current) ? "up" : Number(value) < Number(prev.current) ? "down" : "same";

  useEffect(() => {
    prev.current = value;
    const el = ref.current;
    const now = performance.now();
    if (!el || dir === "same" || reduced || now - lastFlash.current < MIN_FLASH_GAP_MS) return;
    lastFlash.current = now;
    el.getAnimations().forEach((a) => a.cancel());
    el.animate(
      [{ backgroundColor: FLASH[dir] }, { backgroundColor: "transparent" }],
      { duration: 600, easing: "ease-out" },
    );
  }, [value, dir, reduced]);

  return (
    <td ref={ref} className="tabular-nums" data-dir={dir}>
      {formatPrice(value, decimals)}
    </td>
  );
}
The price cell flashes at most every 500 ms, skips the flash when the user asked for less motion, and always shows direction in text.

Reduced motion and color-blind users

  • Reduced motion. When prefers-reduced-motion: reduce matches, or the user turns off "Flash prices" in settings, skip the background animation. Direction still shows through the arrow and the colored change text, which don't move.
  • Not color alone. Red-green color blindness is the most common kind, affecting roughly 1 in 12 men of Northern European descent. Always pair color with an arrow (▲ or ▼) and a sign (+1.23 or -0.45). WCAG 1.4.1 Use of Color asks for exactly this; the visual-accessibility lesson covers it in depth.
  • Color meaning is cultural. In mainland China and several other East Asian markets, red means up and green means down. Make the up and down colors CSS variables tied to a user setting, never hard-coded.
  • Contrast. Green text on white often fails 4.5:1. Use darker greens and reds for text and keep the bright tints for the brief background flash.

The lab below plays three seconds of one symbol. Turn the rate up to 12 changes per second and count the flashes in any one second; then turn on the 500 ms rule, reduced motion, and the different feed states to see what the row shows when the connection drops, trading halts, the market closes or the data is delayed.

Changes per second
Feed
Three seconds of NVDA at 12 changes per second, feed live. 36 changes and 36 flashes; at most 13 flashes in any one second, over the limit of 3. The row shows NVDA 142.08 with status Live.flashes (green up, red down)0 s1 s2 s3 smax flashes in any 1 s: 13 (WCAG 2.3.1 limit: 3)NVDA142.08▲ +0.12Livedirection shown by arrow and sign, not color alone
36 flashes in 3 seconds; at most 13 in any one second. Over the WCAG 2.3.1 limit of 3.

Check your understanding · Exercise 7

A symbol ticks up 12 times in one second. With our rules, what does the user see? Select all that apply.

The live chart

The symbol page has an intraday chart: a line or candles for today, growing at the right edge. Loading it is ordinary: GET /bars?symbol=AAPL&interval=1m&from=09:30 returns about 390 one-minute bars for a full regular session. A bar (or candle) summarizes an interval with five numbers: open, high, low, close, and volume (OHLCV). The live part is the hard part: how does the newest bar keep up with the ticks?

The naive version pushes every tick as a new point. In a busy hour that is tens of thousands of points, the line becomes a fuzzy band, and redraws get slower as the array grows. Charts don't want ticks; they want bars at the zoom level the user is looking at.

Decision Where is the live bar built?

Client builds bars from quotes

Each quote's last price updates the current bar: set high and low, set close; a new minute starts a new bar.

Pros

  • No extra channel; works for any bar size, including 1-second bars.

Cons

  • With conflation, the client never sees the ticks in between, so highs and lows are missed.
  • Volume per bar has to be estimated from changes in the day volume.

Server sends the in-progress bar

A bar subscription pushes the current minute's OHLCV, conflated, and a final bar when the minute closes.

Pros

  • Exact highs, lows and volume, built from every trade on the server.

Cons

  • One more subscription; only for the bar sizes the server supports.

Both: server bars are the truth, the client draws ahead

The client updates the last bar from quotes between server bar messages; each server bar overwrites it.

Pros

  • Looks as live as the price; corrects itself every update.

Cons

  • A short moment where the last bar's high or low may be slightly off.

What we pick: Use server bars as the source of truth and let the client draw ahead from quotes. The big price and the chart's last point then move together, and the server's bar fixes any high or low that conflation hid. This is the same rule as quotes: the server owns the facts, the client owns the smoothness.

ts
type Bar = { start: number; o: number; h: number; l: number; c: number; v: number };

function applyTick(bars: Bar[], price: number, size: number, ts: number, ms = 60_000) {
  const start = Math.floor(ts / ms) * ms;
  const last = bars[bars.length - 1];
  if (!last || start > last.start) {
    bars.push({ start, o: price, h: price, l: price, c: price, v: size });
    return "new-bar";
  }
  if (start < last.start) return "late"; // belongs to a closed bar: ignore
  last.h = Math.max(last.h, price);
  last.l = Math.min(last.l, price);
  last.c = price;
  last.v += size;
  return "updated";
}

function applyServerBar(bars: Bar[], bar: Bar) {
  const i = bars.findLastIndex((b) => b.start === bar.start);
  if (i >= 0) bars[i] = bar;   // the server's bar overwrites our estimate
  else bars.push(bar);
}
Folding a price into the current bar. Bar boundaries come from exchange time, never the device clock.
A price line across three minutes, 14:30, 14:31 and 14:32, split at the minute boundaries by exchange time. A short spike in 14:31 is drawn dashed: only the server saw it. The 14:30 bar has open 200.90, high 201.40, low 200.80, close 201.20, final from the server. The 14:31 bar has open 201.20, high 201.90 from the spike, low 201.10, close 201.30, final from the server. The 14:32 bar, open 201.30, high 201.50, low 201.25, close 201.45, is in progress: the client draws it ahead from quotes and the server's bar overwrites it.spike inside one 250 ms interval: only the server saw it14:3014:3114:32open 200.90high 201.40low 200.80close 201.20open 201.20high 201.90low 201.10close 201.30open 201.30high 201.50low 201.25close 201.45final, from serverfinal, from serverlive: client draws ahead,server bar overwrites
Each tick lands in the bar for its minute of exchange time. The client keeps the last bar moving with the price, and the server's bar fixes the high that conflation hid.

Rendering is canvas, redrawn at most once per frame from the same frame flush as the table, and only when the last bar changed or a new one started. Panning and zooming are the analytics-dashboard guide's territory, including downsampling with LTTB (Largest-Triangle-Three-Buckets) when a multi-year view has more points than pixels.

Two market-specific touches matter here. Draw a dashed line at the previous close, so the user can see at a glance whether the day is up or down. And shade or gap the pre-market and after-hours parts of the axis, so a thin overnight move doesn't look like a regular-session crash.

Check your understanding · Exercise 8

The server conflates quotes to 250 ms. A stock spikes to 205.00 and falls back to 201.20 within 100 ms. What does a chart built only from client quotes show? Select all that apply.

Background tabs, battery and price alerts

Traders keep a dozen tabs open. A ticker in a hidden tab still receives every message, parses it and updates its store, all for a screen no one sees; on a laptop that costs CPU and battery, and on a phone mobile data too. The browser helps a little: requestAnimationFrame callbacks don't run in hidden tabs, so our per-frame flush stops by itself. But socket messages still arrive and are still parsed.

Decision What happens to the subscription when the tab is hidden?

Keep everything

Stay subscribed at full rate; only rendering pauses because frames stop.

Pros

  • Prices are current the instant the user returns.

Cons

  • Parsing and network for nothing; multiplied by every open tab.

Slow down, then drop

On visibilitychange to hidden, ask the server for a slow rate (one update per 5 seconds).

After 5 minutes hidden, unsubscribe everything and close the socket.

Pros

  • Short tab switches return to almost-current prices.
  • Long-hidden tabs cost nothing.

Cons

  • Returning after a long time needs a reconnect and snapshots, about one round trip.

Drop at once

Close the socket as soon as the tab is hidden; reconnect on return.

Pros

  • The cheapest option.

Cons

  • A two-second glance at another tab costs a full reconnect; the screen flickers to stale and back.

What we pick: Slow down, then drop. It matches how people use tabs: most switches are short, and a slower rate keeps those cheap and nearly current, while tabs hidden for long cost nothing. On return, resubscribe at the normal rate; the snapshots bring every visible row up to date in one round trip, and the rows show the stale style until they do. The countdown-timer guide covers how browsers throttle timers in hidden tabs, which is why we don't rely on a timer to notice the return; we listen for visibilitychange.

With several tabs of the same app open, each opening its own socket multiplies the cost. A SharedWorker (or one leader tab elected over BroadcastChannel) can own a single socket and fan quotes out to tabs. It is a real saving for power users, but it adds complexity, and SharedWorker only reached Chrome for Android in version 148 (2026), where its lifetime in the background is less predictable than on desktop. Treat it as an optimization, not the baseline.

Price alerts

"Tell me when NVDA goes above 150." The naive version checks the condition in the browser on each quote. That only works while the tab is open, subscribed to NVDA, and not throttled, and with conflation it can miss a quick touch of 150 that reversed within the interval. Alerts belong on the server, which sees every trade and runs whether the user is online or not.

  • The client creates the alert with POST /alerts (symbol, condition, price) and shows it in a list. Creating it is an ordinary form with validation: a target on the wrong side of the current price fires at once, so warn the user.
  • The server evaluates alerts against the full trade stream and fires each one once, then marks it triggered.
  • Delivery: if the app is open, a message on the socket shows an in-app toast. If not, Web Push delivers a system notification through the service worker. On iPhone and iPad, Web Push only works for web apps added to the Home Screen (iOS 16.4 and later). The push-notifications lesson covers subscriptions and permission timing.
  • Ask for notification permission when the user creates their first alert, not on page load. A permission request with a clear reason is far more likely to be accepted.
Sequence diagram of a price alert. The user asks the app for an alert when NVDA reaches 150. The app asks to allow notifications, and the user allows. The app sends POST /alerts to the alerts server. Later, with the tab closed, the alerts server sees a trade above 150 and sends a push through the push service, which wakes the service worker with a push event, and the worker shows a notification to the user.UserAppAlertsPushWorkerNVDA at 150allow alerts?AllowPOST /alertstab closedpush: 150.02push eventnotification
Permission is asked at the moment it makes sense, when the first alert is created. The alert fires from the server's full trade stream, so it works with the tab closed and can't miss a quick touch that conflation would hide.

Check your understanding · Exercise 9

A user switches to another tab for 10 seconds and comes back. With "slow down, then drop", what happens? Select all that apply.

Accessibility: following a price without the noise

A sighted user glances at a price when they want it, and a screen reader user needs the same thing: the price when they ask for it, not a voice reading every tick. The naive version puts aria-live="polite" on each price cell. With 25 visible rows updating several times per second, the screen reader queues hundreds of announcements per minute and never catches up. The noise drowns out everything else on the page, even the navigation.

  • No live regions on cells. The watchlist is a real table with a th per row (scope="row"), so a screen reader user can move through cells and hear the current value when they land on it. Values change in place, so focus and reading position stay put.
  • Announce on demand. The symbol page has a "Read price" button (and a keyboard shortcut) that writes one sentence into a single polite live region: "Apple, 201.15 dollars, up 2.75, plus 1.39 percent, as of 2:32 PM". The region is updated only when asked.
  • Opt-in announcements. For users who want to follow one symbol hands-free, offer "announce every minute" or "announce moves over 1 percent", rate-limited, and off by default.
  • Full words in the accessible name. The arrow ▲ reads oddly or not at all; the cell's accessible text says "up 2.75" (visually hidden), while the arrow is aria-hidden.
  • Status changes are announced once. "Connection lost, prices may be out of date" and "Reconnected" go to the polite live region, because they change how the user should trust everything on screen.

A pause control

WCAG 2.2.2 (Pause, Stop, Hide, level A) applies to information that updates automatically, starts by itself and is shown next to other content: there must be a way to pause it, stop it, hide it, or control how often it updates. A ticker is exactly that. People with attention or reading difficulties, and anyone copying a number, need the screen to hold still.

The pause button freezes rendering, not data. The store keeps receiving quotes; the UI stops reading from it and shows a "Paused at 14:32:05" pill so the frozen numbers are clearly labelled. Resume jumps to the newest values in one frame, with no burst of flashes, which is what the WCAG guidance itself recommends for real-time information such as a stock ticker.

A second control, "Update speed: live, every 5 seconds, every 30 seconds", covers the "control how often" part and doubles as a battery saver. Both settings are remembered per user.

Think first

Why should pause freeze the screen but keep the subscription, instead of unsubscribing?

Check your understanding · Exercise 10

Which choices make the ticker usable with a screen reader? Select all that apply.

Architecture, data model and interface

With the decisions made, the architecture almost draws itself. The rule behind every boundary: fast data stays out of React until the frame flush, and only one module talks to the socket. Components ask for symbols; they never see messages.

PieceResponsibilityWhy the boundary is here
Market socketOne WebSocket per tab: connect, heartbeat, backoff, send subscribe batches, parse framesReconnect and parsing logic in one place; easy to move into a worker later
Subscription managerReference counts per symbol, linger, batching, rate per view, resubscribe on reconnect, slow-down when hiddenComponents declare what they show; the manager turns it into minimal messages
Quote storeLatest quote and snapshot fields per symbol, seq checks, dirty set, one flush per frameHigh-frequency server state outside React; per-symbol listeners
Status storeConnection state machine, feed status per exchange, session, entitlement, pausedChanges rarely; read by the pill, every row's stale style, and the live region
Bars storeBars per symbol and interval: REST history plus live server bars and client draw-aheadCharts need arrays; quotes need single values
Server cache (React Query or similar)Watchlists, symbol details, alerts, bar history over RESTOrdinary request-response data with caching and invalidation
UIWatchlist table, rows, price cells, chart canvas, status pill, alert form, live regionReads from the stores with hooks; writes only through actions
Service workerReceives Web Push for alerts, shows notifications, opens the symbol page on clickRuns when no tab is open
Architecture. Inside the browser tab, the UI row has the Watchlist, the Symbol page and the Status pill. Below them are three stores: the Quote store, the Bars store and the Status store. Below those, the Subscription manager, fed by the watchlist's visible rows, and the Market socket with heartbeat and backoff. At the bottom, the Server cache holds REST data. Outside the tab, the Push service feeds the Service worker, the Gateway exchanges subscriptions and quotes with the Market socket, and the REST API serves the Server cache.Browser tabWatchlistSymbol pageStatus pillQuote storeflush per frameBars storeper intervalStatus storeconnectionSub managerref counts, lingerMarket socketheartbeat, backoffServer cache (REST data)Push serviceService workeralerts when closedGatewayconflationREST API
Fast data flows up from the socket through the stores to the UI, and only the socket talks to the gateway. Components declare which symbols they show; they never see a raw message.

Follow one price change end to end. The exchange prints a trade; our market data servers normalize it and update the latest quote for AAPL. The gateway that holds our socket conflates per subscription and, at the next 250 ms tick, sends one quote frame. The market socket parses it and hands it to the quote store, which checks seq, stores it, and marks AAPL dirty.

At the next animation frame, the store notifies the AAPL row and the chart. React renders that one row, the price cell flashes if its throttle allows, and the chart redraws its last bar. Writes go the other way through REST: creating an alert or editing a watchlist is a normal mutation, and a changed watchlist simply changes which rows are visible, which changes subscriptions.

Data flow. On the top row, the exchange sends ticks to our market data servers, which normalize them and pass the latest quote to the gateway, which conflates every 250 milliseconds and sends WebSocket frames to the socket in the browser, which parses them. The socket passes Quote objects down to the quote store, which checks seq and marks dirty symbols. The frame flush notifies rows and the chart, which paint pixels for the user once per frame. On the bottom row, the user's alert form sends POST /alerts to the REST API, which stores the rule in the alerts engine.tickslatest quoteWS framesExchangeevery tradeMarket datanormalizeGatewayconflate 250 msSocketparseQuoteQuote storeseq, dirtyFrame flushrAFRows + chartrendernotifydirty symbolspixelsuserAlert formREST APIAlertsPOST /alertsstore rule
Updates are dropped on purpose in exactly two places: conflation in the gateway and the per-frame flush in the browser. Writes take the ordinary REST path.

Why not put quotes in the same server cache as everything else? Request caches like React Query are built for data fetched by key and refreshed now and then. Feeding them hundreds of setQueryData calls per second works against their design: each call notifies observers and may trigger re-renders and garbage. Keeping the quote store separate, with its own frame flush, is the right tool for a different job.

Data model and interface

The client keeps a few kinds of data, and each lives where its update rate and lifetime fit. The rule above shows up again: the faster something changes, the further it stays from React state.

ts
type SymbolMeta = {
  symbol: string; name: string; exchange: string;
  currency: string; tickSize: string; decimals: number;
  prevClose: string; delayedBy: number;   // seconds; 0 = real-time
};

type Quote = {
  symbol: string; seq: number; ts: number; receivedAt: number;
  last: string; bid: string; ask: string; volume: number;
  session: "pre" | "regular" | "post" | "closed" | "halted";
};

type Connection =
  | { state: "connecting"; attempt: number }
  | { state: "live"; since: number }
  | { state: "reconnecting"; attempt: number; retryAt: number }
  | { state: "offline" };

type Alert = {
  id: string; symbol: string; op: "above" | "below";
  price: string; status: "active" | "triggered" | "cancelled";
};

type Watchlist = { id: string; name: string; symbols: string[] };
Client-side entities. SymbolMeta comes with the snapshot and rarely changes; Quote changes many times per second.
DataLives inWhy
QuoteQuote store (plain Map outside React)Changes many times per second; read per symbol
SymbolMetaQuote store, filled from snapshots; also cached by the server cache for search resultsNeeded to format every price
Connection, feed statusStatus storeRare changes, read by many components
BarsBars store, keyed by symbol and intervalArrays that grow at the end
Watchlist, AlertServer cache (REST)Normal fetch, mutate, invalidate
Selected symbol, chart rangeURL (/stocks/AAPL?range=1d)Shareable and survives reload
Flash colors, update speed, pausedUser settings (server) plus localStorage for first paintPreferences that follow the user
The client data model in four places. The quote store, outside React, holds Quote, changing many times per second, and SymbolMeta from snapshots. The status store holds Connection and feed status, changing rarely. The server cache holds Watchlist and Alert from REST. The URL holds the selected symbol and chart range, such as /stocks/AAPL?range=1d.Quote store (outside React)Quotemany per secondSymbolMetafrom snapshotStatus storeConnectionstate machineFeed statusper exchangeServer cache (REST)Watchlistsymbols[]Alertabove / belowURL/stocks/AAPL?range=1dsymbol and chart range
The faster data changes, the further it stays from React state. Quotes live in a plain store with a frame flush; watchlists and alerts are ordinary cached server data.

REST endpoints

EndpointPurposeNotes
GET /watchlistsThe user's lists and their symbolsCached; the first paint of the watchlist
GET /quotes?symbols=AAPL,MSFTSnapshots without a socketServer-rendered first paint and the polling fallback; short Cache-Control for delayed data
GET /bars?symbol=AAPL&interval=1m&from=...Chart historyPaged by time; returns nextFrom for earlier data
POST /alertsCreate an alertValidates side of price; returns the alert
GET /market/statusSessions, holidays, haltsLets the UI label closed markets even before the socket connects

Socket messages

ts
type ServerMessage =
  | ({ type: "snapshot" } & Quote & SymbolMeta)
  | ({ type: "quote" } & Quote)
  | { type: "bar"; symbol: string; interval: "1m"; bar: Bar; final: boolean }
  | { type: "status"; scope: string; feed: "ok" | "delayed" | "halted" | "closed" }
  | { type: "alert"; alertId: string; symbol: string; price: string; ts: number }
  | { type: "pong"; ts: number }
  | { type: "error"; id: number; code: "UNKNOWN_SYMBOL" | "NOT_ENTITLED" | "RATE_LIMITED"; message: string };
Server to client. Every message has a type; errors name the request they answer.

Two errors deserve UI. UNKNOWN_SYMBOL removes the row's spinner and shows "Not found". NOT_ENTITLED means the server will only send delayed data for this symbol, and the snapshot that follows carries delayedBy, so the row gets its "Delayed" badge. RATE_LIMITED (too many subscribe messages) is a bug signal for us: the subscription manager's batching should prevent it.

Running it in the interview

In 45 minutes you can't cover every chapter in depth. Pick the spine: data that replaces itself, one socket that follows the screen, one render per frame, and honest states. Then go deep wherever the interviewer pulls.

MinutesWhat to doWhat to draw or say
0 to 5ScopeMarkets, real-time vs delayed, list size, rate of change, alerts. Write the numbers down: 300 symbols, 25 visible, bursts of 50 per second per symbol
5 to 10The dataQuote vs trade vs book; a quote message with seq; snapshot then updates; sessions and delayed labels
10 to 18Transport and subscriptionsOne socket, visible-only subscriptions with ref counts and linger, server conflation, reconnect with snapshots
18 to 28RenderingThe quote store outside React, one flush per frame, per-symbol hooks; the flash with throttling and reduced motion
28 to 35Architecture and data flowDraw the boxes: socket, subscription manager, stores, UI, service worker
35 to 45Deep dives on requestStaleness, the chart's bars, background tabs, alerts, accessibility

Draw the data flow first, as a horizontal line from exchange to pixel, and mark the two places where updates are dropped on purpose: conflation on the server and the frame flush on the client. Most of the discussion hangs off that one line. Say the numbers out loud; "1,250 messages per second for the visible rows becomes at most 100 with 250 ms conflation, and at most 60 renders per second" is far stronger than "we'll throttle it".

Think first

Follow-up: "A pro user wants 2,000 symbols on one screen, all visible on four monitors." What changes?

Think first

Follow-up: "The symbol page is server-rendered for SEO. How do you avoid a jump when the live price arrives?"

Check your understanding · Exercise 11

The interviewer asks: "Where do you drop updates, and why is that safe?" Which parts belong in a strong answer? Select all that apply.

Summary

  • A quote is state, not an event: the newest one replaces the old, which makes it safe to drop intermediate quotes on the server (conflation) and on the client (one flush per frame). Trades and order book deltas are events and can't be dropped.
  • Per-symbol sequence numbers drop old messages and detect gaps; snapshots on subscribe and on reconnect make the client correct again without replaying anything.
  • Use one multiplexed WebSocket per tab, subscribe only to visible symbols with reference counts, linger and batching, and let each view ask for its own update rate.
  • Keep quotes in a store outside React, notify only changed symbols once per animation frame, and read them with a per-symbol useSyncExternalStore hook; add direct DOM writes only for the hottest cells.
  • Flash with the Web Animations API, at most once per cell every 500 ms, never on color alone, and not at all under reduced motion.
  • Format from the instrument's tick size and currency with cached Intl.NumberFormat instances and tabular-nums, so columns never shiver.
  • Decide staleness from connection health and server feed status, not per-symbol timers; keep the last value with its time, and never confuse stale with halted, closed or delayed.
  • Server owns the facts (bars, alerts, entitlement); the client owns the smoothness. Screen reader users get prices on demand, and everyone gets a pause control.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next