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.
| Question | Answer we assume | What it changes |
|---|---|---|
| Which markets? | US stocks and ETFs, plus crypto that trades 24/7 | Market sessions, closed states, decimals per instrument |
| Real-time or delayed? | Real-time for users with a market data entitlement, 15-minute delayed for others | Every 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 once | Subscribe 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 moments | Conflation 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 feature | We show last price and bid/ask, not the full order book |
| Which devices? | Desktop and mobile web; mid-range Android phones matter | A tight main-thread budget and battery care |
| Alerts? | Yes: "tell me when AAPL crosses 200", even when the app is closed | Alerts 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.
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.
- 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.
- 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.
- 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.
- 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.
- 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?
For a reading-only page with delayed data, polling every few seconds can be fine, and some finance portals do something close to it. But it has three costs here.
First, latency: with a 2-second poll, a change waits on average 1 second plus the round trip before the user sees it, which feels broken next to a broker that updates within a quarter of a second. Second, cost: 300 symbols polled by a million users is a lot of requests carrying mostly unchanged data. Third, correctness: polling gives you snapshots with no order between them, so detecting a missed change is hard. A push stream with sequence numbers, conflated on the server, gives low latency and order at a lower cost.
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.
| Kind | What it is | Example | Can you drop old ones? |
|---|---|---|---|
| Quote (top of book) | The best price buyers offer (bid) and sellers ask (ask), with sizes, plus the last trade price | AAPL bid 201.14 x 300, ask 201.16 x 200, last 201.15 | Yes: only the newest quote matters |
| Trade (print) | One execution: price, size, time | 100 shares at 201.15 at 14:32:05.123 | No, if you show a trade list or count volume; each one is an event |
| Order book (depth) | Every price level with the size waiting there | 10 levels of bids and 10 of asks | No 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.
A quote message
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";
};
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:
seqlower than or equal to the stored one: an old or duplicate message, for example from before a reconnect. Drop it.seqexactly one higher: apply it.seqmore 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.
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
| State | When (US stocks, Eastern Time) | What the UI shows |
|---|---|---|
| Pre-market | Commonly from 4:00 to 9:30 | Price with a "Pre-market" label; change vs previous close; thinner volume |
| Regular | 9:30 to 16:00 | Live price, no extra label |
| After-hours | Commonly 16:00 to 20:00 | The 16:00 closing price plus a separate after-hours price and change, as many finance sites show it |
| Closed | Nights, weekends, holidays | Last price with "At close" and the close time; no flashes; no stale warning |
| Halted | An 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/7 | Crypto | Always 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?
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
subscribeon the first andunsubscribeon 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.
{ "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 }
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.
| Approach | Messages per second for 25 visible symbols in a burst (50 changes per second each) | Good for |
|---|---|---|
| Every tick | 1,250 | Trading screens that show time and sales; professional tools |
| Conflate at 100 ms | at most 250 | The symbol page's big price and chart |
| Conflate at 250 ms | at most 100 | Watchlist rows |
| Conflate at 1,000 ms | at most 25 | Background 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.
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?
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
| State | Trigger | What the row shows |
|---|---|---|
| Live | Connected, feed healthy, market open | Normal price, flashes on |
| Delayed | Snapshot has delayedBy above 0 | "Delayed 15 min" badge; exchange time shown; flashes off or muted |
| Stale | Socket reconnecting, heartbeat missed, or feed status delayed | Price kept but greyed, a clock icon, "as of 14:32:05"; flashes off |
| Halted | Status message from the server | "Halted" badge; not greyed as stale, because the frozen price is correct |
| Closed | Session closed | "At close" with the close time; no stale warning |
| Paused | The 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.
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?
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.
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);
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>
);
});
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.
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.
| Instrument | Typical step | Shown 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 2027 | 201.15 |
| US stock under $1 | Down to $0.0001 | 0.4321 |
| Bitcoin in US dollars | $0.01 | 67,412.35 |
| A low-priced crypto token | Many decimal places | 0.00001234 |
| A crypto quantity | Up 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.
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.
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.")
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?
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.
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>
);
}
Reduced motion and color-blind users
- Reduced motion. When
prefers-reduced-motion: reducematches, 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.23or-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.
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?
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.
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);
}
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?
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.
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
tablewith athper 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 isaria-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?
Because the user will resume, and when they do they expect current prices at once. If we kept the subscription, resume is instant: the store already holds the newest quotes. If we unsubscribed, resume would show stale values for a round trip while snapshots arrive.
Pausing the UI is also not the same as pausing the facts. Alerts, the connection status and the stale logic keep working while paused, so if the connection drops, the paused pill can still say so.
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.
| Piece | Responsibility | Why the boundary is here |
|---|---|---|
| Market socket | One WebSocket per tab: connect, heartbeat, backoff, send subscribe batches, parse frames | Reconnect and parsing logic in one place; easy to move into a worker later |
| Subscription manager | Reference counts per symbol, linger, batching, rate per view, resubscribe on reconnect, slow-down when hidden | Components declare what they show; the manager turns it into minimal messages |
| Quote store | Latest quote and snapshot fields per symbol, seq checks, dirty set, one flush per frame | High-frequency server state outside React; per-symbol listeners |
| Status store | Connection state machine, feed status per exchange, session, entitlement, paused | Changes rarely; read by the pill, every row's stale style, and the live region |
| Bars store | Bars per symbol and interval: REST history plus live server bars and client draw-ahead | Charts need arrays; quotes need single values |
| Server cache (React Query or similar) | Watchlists, symbol details, alerts, bar history over REST | Ordinary request-response data with caching and invalidation |
| UI | Watchlist table, rows, price cells, chart canvas, status pill, alert form, live region | Reads from the stores with hooks; writes only through actions |
| Service worker | Receives Web Push for alerts, shows notifications, opens the symbol page on click | Runs when no tab is open |
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.
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.
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[] };
SymbolMeta comes with the snapshot and rarely changes; Quote changes many times per second.| Data | Lives in | Why |
|---|---|---|
Quote | Quote store (plain Map outside React) | Changes many times per second; read per symbol |
SymbolMeta | Quote store, filled from snapshots; also cached by the server cache for search results | Needed to format every price |
Connection, feed status | Status store | Rare changes, read by many components |
| Bars | Bars store, keyed by symbol and interval | Arrays that grow at the end |
Watchlist, Alert | Server cache (REST) | Normal fetch, mutate, invalidate |
| Selected symbol, chart range | URL (/stocks/AAPL?range=1d) | Shareable and survives reload |
| Flash colors, update speed, paused | User settings (server) plus localStorage for first paint | Preferences that follow the user |
REST endpoints
| Endpoint | Purpose | Notes |
|---|---|---|
GET /watchlists | The user's lists and their symbols | Cached; the first paint of the watchlist |
GET /quotes?symbols=AAPL,MSFT | Snapshots without a socket | Server-rendered first paint and the polling fallback; short Cache-Control for delayed data |
GET /bars?symbol=AAPL&interval=1m&from=... | Chart history | Paged by time; returns nextFrom for earlier data |
POST /alerts | Create an alert | Validates side of price; returns the alert |
GET /market/status | Sessions, holidays, halts | Lets the UI label closed markets even before the socket connects |
Socket messages
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 };
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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope | Markets, 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 10 | The data | Quote vs trade vs book; a quote message with seq; snapshot then updates; sessions and delayed labels |
| 10 to 18 | Transport and subscriptions | One socket, visible-only subscriptions with ref counts and linger, server conflation, reconnect with snapshots |
| 18 to 28 | Rendering | The quote store outside React, one flush per frame, per-symbol hooks; the flash with throttling and reduced motion |
| 28 to 35 | Architecture and data flow | Draw the boxes: socket, subscription manager, stores, UI, service worker |
| 35 to 45 | Deep dives on request | Staleness, 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?
Visible-only subscriptions no longer save much, so the budget moves to the client. Use server conflation at 500 ms or 1 s for the grid, move the socket and parsing into a Web Worker that posts one batch per frame, and switch the hot cells to direct DOM writes or a canvas grid, so a frame updates text nodes without React diffing 2,000 rows.
Flashes are throttled per cell anyway, but at this density offer a "no flash" mode by default, and sort or highlight by movement instead. The server side may need a dedicated connection tier for pro users.
Think first
Follow-up: "The symbol page is server-rendered for SEO. How do you avoid a jump when the live price arrives?"
Render the snapshot from GET /quotes on the server, with its exchange time and the right label ("As of 3:59 PM", "Delayed 15 min" or "At close"). Hydrate the quote store with the same snapshot, including its seq, so the first socket message is either newer (applied) or older (dropped).
Reserve the width of the price with tabular digits and fixed decimals, so the live value replaces the text without moving the layout, and don't flash the first live update, because the user didn't see a change; they saw the page load.
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
useSyncExternalStorehook; 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.NumberFormatinstances andtabular-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.
References
- MDN: The WebSocket API The transport, events and close codes
- React: useSyncExternalStore Subscribing components to a store outside React
- MDN: Web Animations API Restarting and cancelling the flash from script
- WCAG 2.2 Understanding: Three Flashes or Below Threshold The flash limit behind our 500 ms rule
- WCAG 2.2 Understanding: Pause, Stop, Hide Why auto-updating prices need a pause control
- MDN: font-variant-numeric Tabular digits for columns that don't move
- MDN: Intl.NumberFormat Currency, compact notation and signDisplay
- MDN: Page Visibility API Slowing down and dropping subscriptions in hidden tabs
- SEC: Minimum pricing increments and access fee caps (2024) The half-penny tick amendments to Rule 612
- Kite Connect: WebSocket streaming A real multiplexed quote socket with subscribe, unsubscribe and modes
Concepts behind it
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client. - Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.
- Local vs global stateWhere each piece of state lives decides what re-renders when it changes: keep it next to its readers, lift it only as far as needed, and use context or a store with selectors only for state that many distant parts share.
- Web push notificationsMessages your server sends through the browser's push service, which wake your service worker to show a notification even when your site isn't open: powerful for re-engagement, and easy to ruin with a bad permission prompt.