cf.completefrontend
My progress
SYSTEM DESIGN · #135

Design a stock trading app

The web trading terminal of a broker such as Zerodha Kite, Groww, Robinhood, Interactive Brokers or Binance: watchlists, live quotes, market depth, candlestick charts, placing and tracking orders, positions and P&L. The hard parts are a flood of market data at the open that must be cut down to what is on screen, an order book that must never silently drift, charts built from history plus live ticks, orders that must never be placed twice or shown as filled when they are not, and a UI that is honest about stale data, closed markets and halts.

The question

Design the web trading terminal for a stock broker. A logged-in user sees a watchlist with live prices, opens a stock to see its market depth and a candlestick chart, places buy and sell orders, tracks them until they fill, and watches positions and P&L update live.

Walk me through how market data reaches the screen at the market open without freezing the page, how you keep the order book correct, how you make sure an order is never placed twice and never shown as filled when it is not, and what the UI does when the connection drops or the market halts.

Real-life examples: Zerodha Kite web, Groww web, Robinhood web, Interactive Brokers Client Portal, Binance spot trading, Upstox Pro web

Requirements: which trading terminal are we building?

A trading terminal is the screen a broker gives you to trade: a watchlist of instruments on the left, a big chart in the middle, the market depth (who wants to buy and sell at which price) on the side, and at the bottom your orders, positions and holdings. Zerodha Kite, Groww, Upstox, Robinhood, Interactive Brokers and Binance all ship a version of it. A trade is a few clicks away, and real money moves when you click.

Several neighbours already cover parts of this, and we link them instead of repeating them. The stock-ticker guide covers one live price list: quotes over a WebSocket, rendering at most once per frame, flashing cells, number formatting, stale rows and price alerts. The iot-dashboard and analytics-dashboard guides cover canvas charts and downsampling long series. The checkout-flow and payment-form guides cover idempotent payments.

The auth-flow guide covers two-factor login and sessions that end in the middle of work. The notifications-center guide covers the inbox and push messages when the tab is closed, which is where fill notifications belong. The multi-tab-sync guide covers keeping several tabs in agreement.

This guide is about what only a trading app has: data that arrives faster than any screen can show, orders whose state belongs to an exchange you do not control, and a user who may lose money if the UI lies for one second. The end of this chapter lists the five hard parts.

Questions to ask first

Ask before you draw. Each answer below changes the design. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
Which markets?Indian cash and F&O (NSE, BSE) as the main case, with notes for US stocks and 24/7 cryptoSessions, holidays, tick and lot sizes, currency and number formats
Are we the broker or a reader?We are the broker's own web app; orders go through our backend to the exchangeWe own the order API, the order updates stream and the audit trail
How big are watchlists?Up to 5 lists of 50 to 100 instruments each; about 25 rows visible at onceSubscribe to what is visible, not to every instrument in every list
How fast is the data?At the 09:15 IST open, a busy list can see thousands of ticks per second in total (an estimate; it depends on the list)Binary packets, subscription modes, conflation per frame
Depth: 5 levels or the full book?Best 5 bids and asks for NSE; a deeper incremental book for crypto pairsSnapshot-only depth is easy; an incremental book needs sequence numbers and resync
Which order types?Market, limit, stop-loss (SL) and stop-loss market (SL-M), plus after-market ordersThe order form, validation, and the order state machine
Devices?Desktop first (the terminal), with a responsive layout for tablets; native apps are separateDense tables, keyboard shortcuts, multi-panel layout

Functional requirements

  • Watchlists: add, remove and reorder instruments; each row shows last traded price (LTP), change and percent change, live.
  • Instrument view: market depth (5 levels of bids and asks with quantities and order counts), OHLC (open, high, low, close), volume, circuit limits.
  • Candlestick chart with intervals from 1 minute to 1 day, live updates, zoom and pan into history, and a few indicators.
  • Order entry: buy or sell, order type, product type, quantity, price and trigger price, with validation and a confirmation for risky orders.
  • Order book: every order of the day with its live status; modify and cancel open orders.
  • Positions (today's trades) and holdings (delivered shares) with live P&L.
  • Clear market state: pre-open, open, closed, halted, holiday; after-market orders when closed.

Non-functional requirements

  • Correctness over speed: a wrong price shown as live, a duplicate order, or a fill that did not happen is worse than a slow screen.
  • Freshness you can see: every price has a known age; anything older than a few seconds during market hours is marked stale.
  • Smooth under load: the main thread stays under about 50 ms per task at the open so clicks on Buy respond at once (the INP budget of 200 ms applies).
  • Bandwidth: the socket carries only what is on screen; we aim for well under 100 KB/s on a busy morning.
  • Security: two-factor login, a session that ends every trading day, no orders from a stale or logged-out tab.
  • Accessible: full keyboard trading, colour is never the only sign of up or down, no flashing for users who ask for reduced motion.

Out of scope

  • The matching engine, the exchange connection and the risk system: they live on the server. We only consume their results.
  • Account opening (KYC), fund transfers and tax reports: separate flows.
  • Algorithmic trading and the public trading API: the app may share the same backend, but we design the UI.
  • Investment advice: the app shows data; it never tells the user what to buy.
Wireframe of the trading terminal. A header shows a connection pill reading Live, 42 ms, the market state NSE open, 10:42 IST, and a day P&L of plus 1,555 rupees. On the left, a watchlist with four rows: INFY 1512.25 up and selected, TCS 3410.10 down, SBIN 812.40 up, and HDFCBANK greyed out because it has no live tick yet. A note says 25 rows visible. In the middle, a one-minute candlestick chart of INFY in IST with Buy B and Sell S buttons under it. On the right, market depth with three asks above three bids. At the bottom, tabs for Orders, Positions and Holdings, with an order row reading BUY 10 INFY, OPEN, 4 of 10 filled.Trading terminal● Live 42 msNSE open 10:42 ISTP&L +₹1,555INFY 1512.25 ▲TCS 3410.10 ▼SBIN 812.40 ▲HDFCBANK25 rows visibleINFY, 1m, ISTBuy (B)Sell (S)depth1512.40 901512.35 2101512.30 451512.20 3401512.15 1201512.10 600Orders 3PositionsHoldingsBUY 10 INFY OPEN 4/10
Every panel reads from one of two pipelines: live market data (watchlist, chart, depth, P&L) or account data (orders, positions). The header carries the connection state, latency and market session, so the user always knows whether what they see is live and tradable.

Check your understanding · Exercise 1

Which answers to your scope questions change the design the most? Select all that apply.

What makes a trading terminal hard

Many apps show live data. A trading terminal is different in five ways, and each one drives a later chapter.

  1. Bursty, replace-only data. At 09:15 IST every instrument starts trading at once. Only the newest tick for a symbol matters, so older ones can be dropped. That is our biggest lever (the market data chapter).
  2. State that must be exact. A late watchlist price is fine if it is labelled. An order book built from small changes is not: miss one change and a price level stays on screen forever (the market depth chapter).
  3. The truth lives elsewhere. The exchange decides an order's state and the broker's backend reports it. The client never decides that an order filled; it shows what it was told, and says so when it is unsure (the orders chapter).
  4. Rules from outside. Exchanges set trading hours, tick sizes, lot sizes, price bands and halts; regulators set login and risk rules. The UI mirrors them to help the user, but the server stays the judge.
  5. Money and trust. A double click that places two orders, a P&L that is off by a rupee, or a green that a colour-blind user can't read all cost real money or real trust. Small UI details here are correctness bugs.

A useful way to frame the whole design: there are two kinds of data, and they get opposite treatment.

AspectMarket dataAccount data
ExamplesPrices, depth, candlesOrders, trades, positions, holdings, funds
VolumeThousands of updates per second at peaksA few events per minute per user
Losing an updateFine for prices: the next tick replaces itNever fine: a missed fill means a wrong position
Source of truthThe exchange feed, through the brokerThe broker's order and position service
Client strategyConflate, sample per frame, drop old ticksApply every event in order, reconcile with a REST fetch
When unsureGrey it out and show its ageShow pending and poll; never guess

Think first

Where does market depth for a crypto pair fall: lossy like prices, or lossless like orders?

Market data at the open: only what is on screen, never stale

At 09:15 IST the NSE normal session opens and every instrument starts trading together. A user with five watchlists of 50 instruments, an open position in a Nifty option and the Reliance chart open is, in principle, interested in about 250 instruments. Each can tick several times per second in the first minutes.

The naive approach: subscribe to everything in full detail

The simplest client subscribes to every instrument in every watchlist, asks for the richest data for each, and puts every message into React state.

Let's put rough numbers on it (estimates, for a busy open): 250 instruments, 4 ticks per second each, 1,000 messages per second. As JSON with depth, about 900 bytes each: roughly 900 KB/s of download, 1,000 JSON.parse calls and 1,000 state updates per second. On a mid-range laptop the main thread is busy all the time; the user presses Buy and may wait half a second for the form to open.

Most of that work is waste. The user sees about 25 watchlist rows and one depth panel. The other 225 instruments are scrolled away or in a hidden list, and nobody reads depth for a row in a list.

Decision What should the socket carry?

Everything, full detail

Subscribe to all instruments in all watchlists, positions and charts in the richest mode.

Pros

  • Switching lists or opening depth is instant: the data is already there.
  • Simplest client code.

Cons

  • Bandwidth and CPU grow with the size of the watchlists, not with the screen.
  • Mobile data and battery suffer; the open feels slow on weak devices.

Visible instruments only, one mode

Track which rows are on screen with IntersectionObserver; subscribe to those, unsubscribe the rest after a short delay.

Pros

  • Cost follows the screen: about 25 rows.
  • Scrolling is cheap: one small subscribe message per batch of rows.

Cons

  • Still pays for depth fields in every row if the mode is rich.
  • Totals that need off-screen prices (portfolio P&L) break.

Visible instruments, mode per use

Each screen region asks for the least data it needs: watchlist rows ask for LTP, the instrument panel asks for full depth, positions ask for LTP even when not visible.

A subscription manager merges the requests: the richest mode wins for each instrument.

Pros

  • Smallest payload for the same screen.
  • P&L stays live because positions keep their own small subscription.

Cons

  • A reference-counted subscription manager to write and test.
  • A row that switches from LTP to full needs one round trip before depth appears.

What we pick: Pick visible instruments with a mode per use. It is how Kite's public WebSocket API is shaped (subscribe by instrument token, then set ltp, quote or full mode per token), and it makes cost depend on what the user looks at. The extra code is one small module.

Modes and binary packets

Kite Connect's documented WebSocket is a good real example. Requests are small JSON messages such as {"a":"mode","v":["full",[408065]]}. Market data comes back as binary frames, each holding one or more packets. Prices are integers in paise (divide by 100 for rupees; currency pairs use a larger divisor), so there is no float parsing and no rounding noise on the wire.

Packet sizes for tradable instruments, from the Kite Connect v3 WebSocket docs. Index packets in quote and full mode are smaller.
ModePacket sizeWhat it holdsWho uses it here
ltp8 bytesInstrument token and last traded priceWatchlist rows, positions
quote44 bytesAdds last quantity, average price, volume, buy and sell totals, OHLCThe instrument header strip
full184 bytesAdds timestamps, open interest and 5 levels of bids and asksThe open instrument's depth panel

Compare that with JSON. An LTP update as JSON, {"token":408065,"ltp":1523.45}, is 30 bytes, nearly four times the 8-byte packet. A full quote with depth as JSON is several hundred bytes (our estimate) against 184. Binary also parses faster: reading int32 values with a DataView allocates no strings or objects per field.

ts
type Tick = { token: number; ltpPaise: number; receivedAt: number };

export function parseFrame(buf: ArrayBuffer, now: number): Tick[] {
  const view = new DataView(buf);
  if (buf.byteLength < 2) return []; // 1-byte heartbeat
  const count = view.getInt16(0);
  const ticks: Tick[] = [];
  let offset = 2;
  for (let i = 0; i < count; i++) {
    const length = view.getInt16(offset);
    offset += 2;
    ticks.push({
      token: view.getInt32(offset),
      ltpPaise: view.getInt32(offset + 4),
      receivedAt: now,
    });
    offset += length; // skip the rest of quote and full packets here
  }
  return ticks;
}
Splitting one binary frame into packets (Kite layout: count, then length-prefixed packets, big-endian).

Conflation: the latest tick per instrument per frame

Even after cutting subscriptions, ticks arrive faster than a 60 Hz screen. Prices replace each other, so we conflate: keep only the newest tick for each instrument in a Map, and once per animation frame hand the whole map to the UI and clear it. If RELIANCE ticks 6 times inside one 16 ms frame, we paint once with the last value. The stock-ticker guide covers the per-frame render and the cell flash; here the new parts are where conflation happens and what must never be conflated.

  • Conflate: LTP, quote fields, 5-level depth snapshots. Each packet is a full picture of its fields, so the last one wins.
  • Never conflate away: order updates, trades, and incremental depth changes (the market depth chapter). Each is a change, not a replacement; dropping one corrupts state.
  • Accumulate, don't drop: volume for the live candle. The candle's high and low must include ticks that were conflated away, so the candle builder sees every tick before conflation (the charts chapter).
Subscriptions by region and mode. Three regions on the left: the watchlist with 25 visible rows, the depth panel with INFY open, and positions with 4 instruments that are off screen. Arrows lead from each to a subscription manager, which reference-counts requests and keeps the richest mode per instrument. An arrow labelled modes leads to the feed socket, which sends binary packets. A note lists the result: 25 rows in ltp mode at 8 bytes, INFY in full mode at 184 bytes, and 4 positions in ltp mode. A line at the bottom says hidden lists and scrolled-away rows are unsubscribed after 2 seconds.Watchlist25 visible rowsDepth panelINFY openPositions4, off screenSub managerref count, max modeFeed socketbinary packetsmodes25 rows: ltp, 8 BINFY: full, 184 B4 positions: ltphidden lists and scrolled-away rows: unsubscribed after 2 s
Cost follows the screen, not the size of the watchlists. A row that is also a position needs only one subscription; if it is the open instrument too, the manager raises it to full and drops it back to ltp when the depth panel closes.

Check your understanding · Exercise 2

A user scrolls a 100-row watchlist quickly from top to bottom. Which behaviours of the subscription manager are right? Select all that apply.

Never show a stale price as live

Here is a real failure mode. A user's laptop sleeps for a minute, or the office Wi-Fi hands over to a phone hotspot. The WebSocket does not always fire close right away: a dead TCP connection can look open for a long time.

The watchlist keeps showing the 10:02 prices, nothing looks wrong, and at 10:05 the user sells at market based on a price that moved 2% since. The data was not wrong. The UI was wrong to present it as live.

Three different kinds of old

SituationHow you detect itWhat the user sees
Connection staleNo bytes at all (not even a heartbeat) for 5 seconds. Kite sends a 1-byte heartbeat every couple of seconds when there is nothing elseA banner ("Reconnecting, prices paused at 10:02:14"), all live prices greyed, flashing stops
Instrument quietConnection healthy, but this instrument has not traded for a while (an illiquid SME stock, a far out-of-the-money option)Normal colour, plus the last trade time on hover and in the depth panel; nothing alarming, it is real
Market not tradingSession state from the server: pre-open, closed, halted, holidayA session label next to the price ("Closed", "Pre-open", "Halted"); the number is the close or the indicative price

Mixing these up causes both kinds of mistake. If you mark every instrument that hasn't ticked for 5 seconds as stale, quiet stocks flicker grey all day and users learn to ignore the signal. If you only watch the socket's close event, a half-dead connection looks healthy.

Note a detail of the Kite format: an ltp packet has no timestamp, only token and price. The full packet carries the last trade time and the exchange time. So for watchlist rows, freshness comes from the connection (when did we last receive any bytes), and the per-instrument trade time is shown only where we already pay for full mode.

A watchdog, not a trust in close events

ts
const STALE_AFTER_MS = 5_000;
let lastByteAt = Date.now();
let hiddenAt = 0;

socket.addEventListener("message", () => {
  lastByteAt = Date.now();
  if (state === "stale") send({ type: "LIVE_AGAIN" });
});

function check() {
  const silentFor = Date.now() - lastByteAt;
  if (state === "live" && silentFor > STALE_AFTER_MS) send({ type: "STALE" });
  if (state === "stale" && silentFor > 3 * STALE_AFTER_MS) {
    socket.close(); // stop waiting for TCP to notice
    send({ type: "RECONNECT" });
  }
}
setInterval(check, 1_000);

document.addEventListener("visibilitychange", () => {
  if (document.hidden) { hiddenAt = Date.now(); return; }
  if (Date.now() - hiddenAt > STALE_AFTER_MS) lastByteAt = Date.now() - STALE_AFTER_MS - 1;
  check();
});
Any received byte, heartbeat included, resets the watchdog. After the tab was hidden or the laptop slept, assume stale until a byte proves otherwise.

The connection is a small state machine, and every price cell reads one value from it. Writing it as explicit states (the state-machines lesson covers the pattern) stops impossible mixes such as "reconnecting but still flashing green".

Feed connection state machine with six states. Connecting goes to Resyncing when the socket opens. Resyncing goes to Live once subscriptions are resent and snapshots are in. Live goes to Stale after 5 seconds without any byte, and Stale goes back to Live when any byte arrives. Stale goes to Reconnecting when the watchdog closes the socket after 15 seconds. Reconnecting goes back to Resyncing when a new socket opens, or to Session ended when the token has expired. A note says prices are live only in Live; in Stale, Reconnecting and Resyncing they are greyed with their age, and orders keep polling REST.Connectingfirst socketResyncingresend, rebuildLiveprices in colourSession endedlog in againReconnectingbackoff + jitterStalegreyed, with ageopensynced5 s silentbyteclosesocket openexpiredprices are live only in LiveStale, Reconnecting, Resyncing: greyed, with their ageorders keep polling REST while the socket is down
Coming back from a drop goes through Resyncing, never straight to Live: subscriptions, books, candles and orders must be rebuilt first. Session ended is a normal state on a trading app, reached every day.

Coming back from reconnecting is not the same as being live. The new socket knows nothing, so the client must resend every subscription and mode and rebuild everything incremental (books, the last candle, the day's orders) before prices regain colour. That is the resyncing state. The reliability chapter lists the steps, and the websockets and retries-and-backoff lessons cover reconnect timing with jitter.

Decision Should the Buy and Sell buttons work while prices are stale?

Block trading

Disable order entry until the connection is live again.

Pros

  • Nobody trades on an old price.

Cons

  • A user who must exit a losing position during a network wobble cannot. The order would go over HTTPS, which may work fine even while the socket is down.

Allow, with no change

Orders go through as usual; the banner is the only signal.

Pros

  • Never in the user's way.

Cons

  • A market order placed while the screen is 30 seconds old can fill far from what the user saw.

Allow, but make staleness part of the order

The order form shows the price's age.

Market and SL-M orders need an extra confirmation while stale; limit orders go through, because the user named the price.

Pros

  • Exits are always possible.
  • The risk is shown exactly where the decision is made.

Cons

  • One more state for the order form to handle and test.

What we pick: Allow, but make staleness part of the order. A limit order is safe on old data because the price is the user's own. A market order is not, so it gets one explicit confirmation that names the age of the price.

Check your understanding · Exercise 3

The socket is healthy and heartbeats arrive, but one small-cap stock has not ticked for 4 minutes. What should its watchlist row show? Pick one.

Market depth: keeping an order book exact

Market depth (also called the order book or the ladder) lists the prices people are waiting to buy at (bids, best first: highest) and to sell at (asks or offers, best first: lowest), with the total quantity at each price. The gap between the best bid and the best ask is the spread. It tells a trader how much they can buy before the price moves against them.

There are two very different feeds, and the interviewer will want to know which one you are handling.

AspectSnapshot depth (NSE, Kite full mode)Incremental depth (crypto, for example Binance diff stream)
Each messageThe full best 5 bids and 5 asksOnly the levels that changed: price and new total quantity
Missing a messageHarmless: the next one is completeThe book is wrong until you rebuild it
Client workReplace the 10 rowsKeep a sorted book, apply changes in order, detect gaps, resync
ConflationKeep the last snapshot per frameApply every change; render once per frame

Snapshot depth needs nothing new: treat it like any other conflated quote. The rest of this chapter is about the incremental kind, because it is where clients go wrong.

The naive approach: apply whatever arrives

Say the book has a bid of 3 BTC at 67,250.10. A seller fills it and the stream sends "67,250.10 is now 0" (zero means remove the level). If that one message is lost (a reconnect, a dropped frame during a tab freeze, a bug in a buffer), the level stays on screen forever.

Soon the book shows a best bid above the best ask, a crossed book, which cannot exist on a real exchange. A user who reads the ghost bid as "I can sell at 67,250" places an order that does not fill.

Sequence numbers, snapshot, and resync

The fix is to number every change. Binance's diff depth stream gives each event a first update id U and a final update id u. Its documented procedure for a local book is, in our words:

  1. Open the stream first and buffer its events. Note the U of the first one.
  2. Fetch a REST snapshot of the book. It carries lastUpdateId. If that is lower than the first buffered U, the snapshot is too old: fetch again.
  3. Drop buffered events whose u is at or below lastUpdateId; they are already inside the snapshot.
  4. Apply the remaining buffered events, then live ones. Each event's U should be exactly the previous u plus 1.
  5. If an event's U is bigger than your book's id plus 1, you missed something. Throw the book away and start again from step 1.
  6. For each level in an event, set the quantity; a quantity of 0 removes the level.

Why open the stream before fetching the snapshot? If you fetch first, changes made between the snapshot and the moment the stream opens are lost forever, and nothing would detect it. Buffering first guarantees an overlap.

Sequence diagram of an incremental order book with four lifelines: Socket, Book worker, REST and Depth UI. The socket sends events 101 to 104, which the worker buffers. The worker asks REST for a snapshot, which returns lastUpdateId 102. The worker drops events up to 102, applies 103 and 104, and posts the top 20 levels with id 104 to the UI. The socket sends an event from 105 to 107, which applies cleanly. Then an event starting at 110 arrives: 108 and 109 are missing, a gap. The worker tells the UI the book is resyncing, and asks REST for a new snapshot.SocketBook workerRESTDepth UI101..104 bufferGET snapshotlastUpdateId 102top 20, id 104105..107 ok110.. gap!resyncingnew snapshot
Open the stream before fetching the snapshot, so the two always overlap. The U of each event must be the previous u plus 1; anything else means a lost change, and only a fresh snapshot can repair it.
ts
type Side = Map<number, number>; // price in ticks -> quantity
type Book = { lastId: number; bids: Side; asks: Side };
type Diff = { U: number; u: number; b: [string, string][]; a: [string, string][] };

export function applyDiff(book: Book, e: Diff, tick: number): "ok" | "skip" | "gap" {
  if (e.u <= book.lastId) return "skip"; // already applied
  if (e.U > book.lastId + 1) return "gap"; // missed events: resync
  for (const [p, q] of e.b) setLevel(book.bids, p, q, tick);
  for (const [p, q] of e.a) setLevel(book.asks, p, q, tick);
  book.lastId = e.u;
  return "ok";
}

function setLevel(side: Side, price: string, qty: string, tick: number) {
  const key = Math.round(Number(price) / tick);
  if (Number(qty) === 0) side.delete(key);
  else side.set(key, Number(qty));
}
Applying one diff event. Prices are kept as integer ticks to avoid float keys like 0.1 versus 0.10.

Two extra guards are cheap and catch bugs the ids cannot: if the best bid is at or above the best ask after an update, resync; and when the socket reconnects, always resync, because the new connection starts a new sequence.

Where does this code run? A busy pair can send an event every 100 ms, each touching dozens of levels, and a snapshot can hold up to 5,000 levels per side. Keep the book in a Web Worker; once per animation frame the worker posts only the top 20 levels per side (about 40 small arrays) to the main thread. Sorting happens in the worker, on the few levels near the top.

Rendering the ladder without layout thrash

  • Fixed rows. The ladder has a fixed number of rows of fixed height. A level that disappears leaves an empty row at the bottom instead of shifting everything, so the row under the mouse does not jump just before a click.
  • Bars with transform. Each row has a quantity bar. Set transform: scaleX(0.42) on a full-width element instead of width: 42%: a transform is composited and does not trigger layout (the reflow-repaint-composite lesson explains why).
  • Stable numbers. Use font-variant-numeric: tabular-nums so digits keep the same width and prices don't wobble.
  • A stable scale. Scale bars against the largest quantity in the visible levels, smoothed over a second or two, so one huge order does not make every other bar collapse and grow each frame.
Order book ladder, status Live, local update id 100. Asks from highest to lowest: 250.40 quantity 12, 250.35 quantity 8, 250.30 quantity 5, 250.25 quantity 3. Bids from highest to lowest: 250.20 quantity 4, 250.15 quantity 9, 250.10 quantity 6, 250.05 quantity 11. 0 levels differ from the exchange. 0 events applied in 0 paints. Lost events: none.Livelocal id 100, sent 100ask250.4012ask250.358ask250.305ask250.253bid250.204bid250.159bid250.106bid250.0511applied 0, paints 0lost: none
The local book matches the exchange.

Snapshot loaded: update id 100. Send events one at a time, or a burst.

Check your understanding · Exercise 4

Your local book's id is 1,040. The next event arrives with U = 1,043 and u = 1,047. What should the client do? Select all that apply.

Candlestick charts: history plus live ticks

A candle summarises trading in one time interval (1 minute, 5 minutes, 1 day) with four prices: open (first trade), high, low and close (last trade), plus the volume traded. A green or hollow body means close above open; red or filled means below. Charts are where traders spend most of their screen time, so the chart must load fast, scroll back months, and keep its last candle live.

The iot-dashboard and analytics-dashboard guides cover canvas versus SVG and downsampling for long series. Here we focus on what is special about candles: where they come from, how the live one grows, and whose clock decides when a new one starts.

Decision Which chart engine?

A general SVG chart library

Draw candles as SVG rectangles and lines with a library such as Recharts.

Pros

  • Already in many design systems; easy styling and DOM accessibility.

Cons

  • Thousands of candles become thousands of DOM nodes; pan and zoom stutter.
  • No financial features: no price scale for ticks, no crosshair, no session gaps.

TradingView Lightweight Charts

An open-source canvas library built for financial series: candles, volume, crosshair, time scale with pan and zoom.

Pros

  • Canvas: smooth with tens of thousands of bars.
  • series.update(bar) updates the last bar or appends a new one, which is exactly the live candle case.
  • Apache 2.0 licence.

Cons

  • The licence asks for attribution to TradingView with a link on the page (a built-in logo option does this).
  • No drawing tools or large indicator set; you add your own.

A full charting product

A licensed, full-featured library such as TradingView Advanced Charts or ChartIQ, with drawing tools and 100+ indicators.

Pros

  • Power users get what they know from other platforms.
  • Indicators and drawings are already built.

Cons

  • A licence agreement with the vendor and a much larger bundle.
  • Less control over data loading and look.

What we pick: Start with Lightweight Charts for the main chart: small, canvas-based and shaped for live candles. Offer a full charting product as an opt-in "advanced chart" page if traders ask for drawing tools; some Indian brokers offer a choice of chart engines for this reason.

The live candle

On load, fetch history over REST (for example the last 2,000 one-minute candles) and draw it. Then every tick updates the last candle: close becomes the tick price, high and low widen if needed. When a tick's exchange time crosses the interval boundary, a new candle starts with open, high, low and close all equal to that price.

ts
const SESSION_OPEN_MIN = 9 * 60 + 15; // 09:15 IST
const IST_OFFSET_MIN = 330; // UTC+5:30, no daylight saving

function bucketStart(exchangeMs: number, intervalMin: number): number {
  const istMin = Math.floor(exchangeMs / 60_000) + IST_OFFSET_MIN;
  const dayStartMin = istMin - (istMin % 1440);
  const sinceOpen = istMin - dayStartMin - SESSION_OPEN_MIN;
  const bucket = Math.floor(sinceOpen / intervalMin) * intervalMin;
  return (dayStartMin + SESSION_OPEN_MIN + bucket - IST_OFFSET_MIN) * 60_000; // back to UTC
}

// volAtStart: the day's cumulative volume when this candle began.
type LiveCandle = Candle & { volAtStart: number };

function onTick(c: LiveCandle, price: number, dayVolume: number, exchangeMs: number, intervalMin: number): LiveCandle {
  const start = bucketStart(exchangeMs, intervalMin);
  if (start < c.start) return c; // late tick: the server candle will fix it
  if (start > c.start) {
    const volAtStart = c.volAtStart + c.v;
    return { start, o: price, h: price, l: price, c: price, v: dayVolume - volAtStart, volAtStart };
  }
  return { ...c, h: Math.max(c.h, price), l: Math.min(c.l, price), c: price, v: dayVolume - c.volAtStart };
}
Bucketing by exchange time, aligned to the session open. Volume comes from the day's cumulative volume in the quote packet.
  • Exchange time, not the browser's clock. The user's laptop may be 40 seconds off, or set to Dubai time. Use the exchange timestamp from the packet when it has one; otherwise use receive time corrected by a server clock offset.
  • Align to the session, not the clock hour. NSE trades 09:15 to 15:30 IST, so hourly candles usually run 09:15 to 10:15 and the last one is short (15:15 to 15:30). Clock-hour candles (09:00 to 10:00) would not match the broker's server candles.
  • Feed every tick, not the conflated stream. If the candle builder only saw conflated ticks, a spike that lasted 5 ms would be missing from the high. The builder sits before conflation.
  • The server's candle wins. Ticks can be missed during a reconnect. On every reconnect and at every interval boundary, refetch the last two candles over REST and replace them. Small corrections are normal.
  • Empty intervals. If nothing trades in a minute, there is no candle. Most charts for Indian and US stocks skip it rather than drawing a flat one; follow your server's convention.

Time zones deserve a sentence in the interview. Lightweight Charts treats times as plain timestamps and does not support time zones natively; its docs suggest shifting each bar's timestamp so the axis shows the zone you want. Show exchange time (IST for NSE, ET for NYSE, with US daylight saving) and say so on the axis.

A trader in London watching Reliance thinks in IST market hours, not London time. Format labels with Intl.DateTimeFormat and timeZone: "Asia/Kolkata".

Ticks across three one-minute buckets in IST, 10:00, 10:01 and 10:02, split by exchange time. A short spike appears early in 10:01. Below, three candles built from the ticks: 10:00 is final and closed above its open; 10:01 has a long upper wick from the spike and closed slightly below its open; 10:02 is still live and drawn dashed, its close moving with each tick. A line at the bottom says ticks are bucketed by their exchange stamp, not their arrival time.ticks by exchange time, IST10:0010:0110:0210:00 final10:01, spike in high10:02 livebucket by exchange stamp, not arrival time
The candle builder sees every tick, before conflation, which is why the 10:01 high includes a spike that lasted milliseconds. The last candle keeps changing until the boundary; on reconnect the server's version replaces it.

Lazy history and indicators off the main thread

Panning left should feel endless. Watch the visible range (Lightweight Charts has subscribeVisibleLogicalRangeChange); when fewer than about 100 bars remain to the left, fetch the previous chunk of 1,000 bars, prepend it and keep the view still. Allow one history request in flight per chart and interval, and drop responses for an interval the user has already left (the request-races lesson covers this).

Indicators such as moving averages, RSI and Bollinger bands are pure functions of the candles. On load or interval change, compute them in a Web Worker over the whole series, so 5 indicators over 20,000 bars never block a click on Buy. On each live tick only the last value changes, and most indicators can update it in constant time (an EMA needs only the previous EMA and the new close).

Check your understanding · Exercise 5

A user in Singapore (UTC+8) opens a 15-minute chart of an NSE stock. Which statements are right? Select all that apply.

Placing and tracking orders

The order window is small: buy or sell, quantity, price, a few toggles, one button. Almost every field hides a rule from the exchange or the broker, and the button hides the most expensive bug in the app: placing the same order twice.

Order types and product types

Kite Connect uses MARKET, LIMIT, SL and SL-M. US brokers such as Robinhood also offer trailing stops.
Order typeWhat the user givesWhat happensUS name
MarketQuantityFills now at the best available prices; the price is not known in advanceMarket
LimitQuantity, priceFills only at that price or better; may wait or never fillLimit
SL (stop-loss limit)Quantity, trigger price, limit priceWaits until the market reaches the trigger, then becomes a limit orderStop limit
SL-M (stop-loss market)Quantity, trigger priceWaits for the trigger, then becomes a market orderStop (stop market)

Indian brokers also ask for a product type, which decides how the position is held. In Kite's API, CNC is delivery: shares bought land in your demat holdings. MIS is intraday with margin: the broker squares the position off (closes it) automatically before the close if you haven't. NRML is for futures and options carried overnight.

The variety decides when the order goes to the exchange: regular now, amo (after-market order) queued for the next session. US apps hide most of this, but have their own choices, such as whether an order may fill in extended hours.

Validation that mirrors the exchange, with the server as judge

Client-side validation exists to save the user a round trip and a rejection. It must use the same rules the exchange uses, taken from data, never hard-coded. The broker's instrument list gives each instrument its tick_size and lot_size; the quote gives the day's circuit limits. The server repeats every check, adds the ones the client cannot do (margin, risk limits, account bans) and its answer is final.

RuleExampleClient check
Tick sizePrice must be a multiple of the instrument's tick. NSE uses 1 paisa for stocks below ₹250 and reviews it monthly, so the tick can change with the priceWork in paise: pricePaise % tickPaise === 0; snap the input with arrow keys
Lot size (F&O)A Nifty option trades in fixed lots; the size is set by the exchange and changes from time to timeqty % lotSize === 0; let the user enter lots and show units
Price band (circuit limits)Many stocks have a daily band such as 2%, 5%, 10% or 20% around the previous closePrice between lower_circuit_limit and upper_circuit_limit from the quote
Trigger relationA buy SL triggers at or below its limit price; a sell SL at or aboveCompare trigger and price for the side
Freeze quantityExchanges cap quantity per order in F&OWarn, or let the server split it (Kite has an autoslice option)
ts
type Rules = { tickPaise: number; lotSize: number; lowerPaise: number; upperPaise: number };

export function validate(o: OrderDraft, r: Rules): string[] {
  const errors: string[] = [];
  if (o.qty <= 0 || o.qty % r.lotSize !== 0)
    errors.push(`Quantity must be a multiple of the lot size, ${r.lotSize}.`);
  if (o.type === "LIMIT" || o.type === "SL") {
    if (o.pricePaise % r.tickPaise !== 0) errors.push("Price must be a multiple of the tick size.");
    if (o.pricePaise < r.lowerPaise || o.pricePaise > r.upperPaise)
      errors.push("Price is outside today's price band.");
  }
  if (o.type === "SL" && o.side === "BUY" && o.triggerPaise > o.pricePaise)
    errors.push("For a buy stop-loss, the trigger must be at or below the price.");
  return errors; // the server checks all of this again, plus margin and risk
}
Rules come from instrument data, and all arithmetic is in integer paise.

When to ask "are you sure?"

Confirming every order trains users to click through. Confirm only when the order is unusual for this user and this moment: a value above a threshold the user can set; a limit price far from the LTP, such as a buy limit 8% above it (a classic fat-finger mistake, like typing 25000 instead of 2500); a market order on an illiquid instrument or while prices are stale; an F&O sell that needs large margin.

The confirmation repeats the order in words ("Sell 1 lot of NIFTY 24500 CE at market, about ₹9,800") and puts focus on Cancel, not on Confirm.

Never place the same order twice

A user double-clicks Buy. Or clicks once, the request takes 8 seconds at a busy open, and the client times out and retries. Or the request reaches the broker, the reply is lost, and the user, seeing an error, clicks again. In each case a naive client places two orders, and the second one may fill.

Unlike a duplicate payment, a duplicate trade cannot be refunded: it must be closed at whatever the market price is now.

Decision How do we stop duplicate orders?

Disable the button while submitting

Set the button to disabled until the response arrives.

Pros

  • Stops the plain double click.
  • One line of code.

Cons

  • Does nothing for retries after a timeout, a lost reply, or the same order from a second tab.

Server deduplicates by content

Reject an order identical to one placed in the last few seconds.

Pros

  • No client changes.

Cons

  • Blocks real repeated orders (a scalper buying 50 shares twice on purpose).
  • Still guesses; it cannot tell a retry from a new intent.

A client order id per intent

When the user confirms, the client creates a UUID for that intent and sends it with the order (for example as an Idempotency-Key header or a clientOrderId field).

Retries reuse the same id. The server stores id to order for the day and returns the existing order for a repeat.

Pros

  • A retry, a lost reply or a double click all map to one order.
  • The client can look the order up by its id after a timeout.

Cons

  • Needs server support and a store of recent ids.
  • The client must keep the id across retries, and across a reload while the result is unknown.

What we pick: Use a client order id for every intent, plus the disabled button for instant feedback. Real APIs show the idea and its limits. Binance accepts a newClientOrderId but rejects a reuse only while the first order is still open, so a market order that filled at once is not protected; Kite lets you attach a tag (up to 20 characters) to find an order later, which is a label, not a duplicate check. That is why our own order API keeps ids for the whole day. The checkout-flow and payment-form guides cover idempotency keys in general; what is special here is the unknown outcome.

If the POST times out, the client does not know whether the order exists. It must not show "failed" and must not create a new id. It shows "Placing… status unknown", retries with the same id, and also watches the order updates stream and the order book for that id; whichever answers first wins. Only an explicit rejection from the server lets the form unlock for a fresh attempt.

Retries need limits too. Brokers rate-limit order calls (Kite's docs list 10 order requests per second per API key), so retry with backoff and stop after a few tries, leaving the order in the unknown state while the stream and the order book keep looking.

Sequence diagram of placing an order with four lifelines: User, Order form, Order API and Exchange. The user clicks Buy and confirms. The form posts the order with idempotency key 7f3c. The API sends the new order to the exchange. The reply to the form is lost and the request times out, so the form shows Placing, status unknown, not failed. The form retries with the same key. The API answers 200 with the same order, not a new one. Later the exchange reports a fill of 10 to the API, and the API pushes an update to the form: complete.UserOrder formOrder APIExchangeBuy, confirmPOST key=7f3cnew orderreply lostunknown, waitretry, same key200: same orderfilled 10COMPLETE
The failure path is the important one. A lost reply leaves the outcome unknown; retrying with the same key, or finding the order by its key in the updates stream, can never create a second order.

Check your understanding · Exercise 6

A Buy request times out after 10 seconds. Which client behaviours are safe? Select all that apply.

Tracking orders: a state machine the exchange drives

After the POST returns an order id, the real story starts. Kite's docs put it plainly: a successful placement does not mean the order executed, and its status is not known at that moment. The order passes through the broker's risk checks (the RMS, or risk management system), reaches the exchange, waits in the book, fills in parts or all at once, or is cancelled or rejected.

The client learns each step from the order updates stream (Kite sends them as JSON text messages on the same WebSocket as the binary quotes) and can always ask the REST order book for the day's orders.

Kite's documented statuses show how many steps there are: the common ones are OPEN, COMPLETE, CANCELLED and REJECTED, with short-lived ones in between such as PUT ORDER REQ RECEIVED, VALIDATION PENDING, OPEN PENDING, TRIGGER PENDING, MODIFY PENDING, CANCEL PENDING and AMO REQ RECEIVED. The UI groups them into a few states a person can read.

Order state machine. Sending goes to Pending on a 201 reply, or to Unknown on a timeout; Unknown goes to Pending when the order is found by its key. Pending goes to Rejected if risk checks fail, or to Open once at the exchange. Open goes to Complete when fully filled, or to Cancelling when the user cancels. Cancelling goes to Cancelled, or to Complete if the fill won the race. A note says stop-loss orders wait in trigger pending before Open, and partially filled means Open with some fills. Rejected, Complete and Cancelled are terminal.Sendingclient onlyPendingRMS, exchangeRejectedwith reasonUnknownclient onlyOpen0..n filledCompleteall filledCancellingwait for serverCancelledkeeps its fills201timeoutfoundfailat exchangefilledcanceldonefill wonSL: trigger pendingbefore Openpartly filled = Open
Sending and Unknown exist only on the client; every other state comes from the broker. The three states on the right are terminal: no later message can move an order out of them.
  • Partially filled is not a status of its own in Kite: an OPEN order with filled_quantity above zero. The UI must show it as "40 of 100 filled, avg ₹1,523.40" because the user now holds a position.
  • Terminal states are final. Once an order is COMPLETE, CANCELLED or REJECTED, no later message moves it back. A late OPEN arriving after COMPLETE (from a slow poll, or a reordered message) is ignored.
  • Fills only grow. filled_quantity never goes down. If a message has a smaller value than what we show, it is older than what we have.
  • A cancelled order can still have fills. "Cancelled" with 30 of 100 filled means 30 shares are in the account. Showing only "Cancelled" is a correctness bug.
ts
const TERMINAL = new Set(["COMPLETE", "CANCELLED", "REJECTED"]);

export function mergeOrder(prev: Order | undefined, next: Order): Order {
  if (!prev) return next;
  if (TERMINAL.has(prev.status) && !TERMINAL.has(next.status)) return prev;
  if (next.filledQty < prev.filledQty) return prev;
  if (next.exchangeUpdatedAt && prev.exchangeUpdatedAt &&
      next.exchangeUpdatedAt < prev.exchangeUpdatedAt) return prev;
  return next;
}
Merging an update from the stream or a REST poll. Both sources go through the same function, so order of arrival does not matter.

Modify and cancel race with fills

The user's Cancel and the market's fill travel on different paths and meet at the exchange. Whatever the user saw when they clicked may already be out of date. Three outcomes are all normal: the cancel wins and nothing filled; part filled first and the rest is cancelled; or everything filled first and the cancel is rejected as too late. A modify has the same problem: changing the quantity to 50 after 60 have filled cannot work.

So the UI never shows the result of a cancel or modify before the server says it. Clicking Cancel moves the row to "Cancelling…" (matching CANCEL PENDING), keeps the live filled quantity visible, and waits. The optimistic-updates lesson explains when guessing the result is fine; for money that has left the user's control, it is not.

Sequence diagram of a cancel racing a fill, with lifelines User, Client, Broker and Exchange. The order is for 100 shares with none filled. The user clicks Cancel and the client sends DELETE order to the broker, showing Cancelling. Before the cancel reaches the exchange, the exchange fills 30 shares. The broker then asks the exchange to cancel the rest, and the exchange confirms 70 cancelled. The broker sends the client an update, open with 30 of 100 filled, and then cancelled with 30 of 100 filled. The client shows Cancelled, 30 filled.UserClientBrokerExchangeCancelDELETE orderCancelling…filled 30cancel rest70 cancelledCANCELLED 30/10030 filled
The user clicked Cancel on an order with nothing filled, and still ended up owning 30 shares. That is normal. The client shows Cancelling until the server answers, then shows the fills that came with the cancel.
Client: tracking. 0 orders at the exchange: none. Position: 0 shares. Recent events: Buy 100 INFY, limit ₹1,510.00. Click Buy to start..Client: trackingposition 0Buy 100 INFY, limit ₹1,510.00. Click Buy to start.
One intent, one order. Step the exchange to see fills arrive.

Buy 100 INFY, limit ₹1,510.00. Click Buy to start.

Check your understanding · Exercise 7

An order for 100 shares shows OPEN with 0 filled. The user clicks Cancel. Then, in this order, the client receives: OPEN with 30 filled, CANCELLED with 30 filled, and from a slow REST poll, OPEN with 0 filled. What should the row show? Pick one.

Positions, holdings and live P&L

Two lists answer "what do I own". Holdings are shares delivered to the user's demat account from earlier days (in India, equity settles one trading day after the trade). Positions are what changed today, plus open futures and options and intraday (MIS) trades.

A share bought today with CNC shows in positions today and in holdings tomorrow. Users get this wrong all the time, so the UI explains it in one line above each table.

P&L (profit and loss) has two parts. Realised P&L is locked in by trades that closed a position. Unrealised (also called mark-to-market, or M2M) P&L is what the open part would make at the current price. Only the unrealised part moves with every tick.

A worked example for a long position. For a short position, unrealised P&L is (average sell price minus LTP) times quantity.
StepTrade or priceRealisedUnrealised
1Buy 100 INFY at ₹1,500.00₹0.00₹0.00
2Sell 40 at ₹1,520.5040 × 20.50 = ₹820.0060 shares left at avg ₹1,500.00
3LTP is ₹1,512.25₹820.0060 × 12.25 = ₹735.00
4LTP drops to ₹1,495.10₹820.0060 × (−4.90) = −₹294.00

Exact money on the client

  • Integers, not floats. In JavaScript, 0.1 + 0.2 is 0.30000000000000004. Keep prices in paise or cents (Kite already sends paise) and quantities as integers; multiply, sum, and divide by 100 only when formatting.
  • Format with Intl.NumberFormat. new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" }) gives ₹1,23,456.78 with Indian lakh grouping; en-US and USD give $123,456.78. Add signDisplay: "exceptZero" so gains show a plus sign. The locale-formatting lesson covers the rest.
  • Recompute per frame, not per tick. Total P&L is a sum over all positions; recompute it once per animation frame from the conflated prices, not on each of hundreds of ticks.

Client estimate, server truth

The client computes unrealised P&L for display, because only the client knows the price on screen right now. The server owns the average price, the realised P&L, and how trades are matched against each other; Kite's positions API, for example, returns average_price, pnl, m2m, realised and unrealised per position.

So fetch positions from the server on load and after every fill event (a fill changes the average price), then move only the unrealised part with live prices between fetches. If the client's number and the server's differ by more than a rupee after a fetch, log it: it is a bug in one of them.

Say what the number excludes. Brokerage, exchange charges and taxes (in India, STT and GST among others) are not in the live P&L; a small "before charges" label avoids angry support tickets after the close.

Check your understanding · Exercise 8

A user sold short 50 shares at ₹820.00 and the LTP is now ₹812.40. Which statements are right? Select all that apply.

Market hours, halts and holidays

A trading app has a calendar built in. What the Buy button does at 08:55 IST is different from what it does at 10:00, at 15:45, or on a Saturday. The naive client hard-codes "open 09:15 to 15:30, Monday to Friday" and checks the browser clock. It breaks on the first exchange holiday, on a special session such as Muhurat trading on Diwali, when the user's clock is off, and on the day the exchange halts for an hour.

A typical NSE equity trading day in IST, not to scale. Pre-open order entry from 09:00 to about 09:10, then order matching and a buffer until 09:15, then the normal market from 09:15 to 15:30, then the closing auction and post-close session until 16:00. A dashed note over the normal market says a halt can come at any time as a server flag. Three notes below: in pre-open, orders are accepted and the price is indicative; in the normal market everything is live; after the close, orders become after-market orders and the price shown is the close.NSE equity day, IST (not to scale)halt: server flag, any timePre-openMatchNormal marketClosing09:0009:1009:1515:3016:00orders acceptedprice: indicativeall liveB / S place ordersafter close: AMOprice: the close
The client never computes this from the browser clock and a hard-coded table. The server sends the session state and the time of the next change, so holidays, special sessions, halts and the 2026 rule changes to the pre-open and closing windows are just different data.
NSE's equity segment as published in late 2026. NSE changed its pre-open rules in September 2026 and moved F&O stocks to a closing auction in August 2026, with more changes announced for December 2026. That churn is exactly why the app reads the schedule from the server. US regular hours are 09:30 to 16:00 ET, with pre-market and after-hours sessions; crypto never closes.
Session (NSE equity, IST)TimeWhat the UI does
Pre-open order entry09:00 to about 09:10 (market orders only until 09:05; entry closes at a random moment in the last two minutes)Orders accepted; show the indicative equilibrium price, labelled "Pre-open"
Pre-open matching and bufferAbout 09:10 to 09:15Order entry closed; show "Opening at 09:15"; queue nothing silently
Normal market09:15 to 15:30 (since August 2026, F&O stocks stop continuous trading at 15:15)Everything live
Closing auction and post-closeAfter continuous trading ends, until 16:00; the exact windows were revised in 2026Show the auction state and the indicative closing price; post-close trades happen at the closing price only
ClosedAfter that, weekends, holidaysOrders become AMOs if the broker allows them; prices labelled "Close"

The fix is to treat market state as server data, like prices. The server publishes a session state per exchange segment (PRE_OPEN, OPEN, CLOSING, CLOSED, HALTED) with the time of the next change, from the exchange calendar it already needs for its own risk checks. The client renders it and uses a corrected clock only to count down to the next change. A trading holiday or a special session is then a data change, not a deploy.

Halts and circuits

  • A stock at its circuit limit. If a stock hits its upper band, it can trade only at that price or below, and often there are no sellers at all. The depth shows zero asks. The UI says "Upper circuit at ₹X, no sellers" next to the price so users understand why their buy does not fill.
  • A market-wide halt. In India, large index moves trigger market-wide circuit breakers at set thresholds (SEBI's framework uses 10%, 15% and 20% index moves), halting equity and derivatives trading on all exchanges for a period that depends on the size of the move and the time of day. Show a full-width banner and stop all live animations.
  • An exchange outage. On 24 February 2021, NSE stopped trading for several hours because of a technical problem. The app must be able to show "Trading halted by the exchange" from a server flag, even while prices still arrive from other venues.

When an action is unavailable, disable it with a reason the user can read, not a silent grey button. "Market closed: this will be placed as an after-market order and sent at the next open" on the button itself is better than a toast after the click. A disabled button cannot receive focus in most browsers, so put the reason in visible text next to it, not only in a tooltip.

Check your understanding · Exercise 9

It is 09:12 IST on a normal trading day. A user opens the order form for an NSE stock. Which behaviours are right? Select all that apply.

Architecture, data model and interface

The architecture follows the split from the requirements chapter: a market data pipeline (high volume, lossy, conflated) and an account data pipeline (low volume, lossless, reconciled with REST). They share one WebSocket but go to different stores with different rules.

Decision Where should the socket and the parsing live?

Main thread

Open the WebSocket in a React effect; parse and conflate in the message handler.

Pros

  • Simplest; no message passing.

Cons

  • Parsing, the order book and the candle builder compete with clicks and rendering at the busiest moment of the day.

A dedicated Web Worker per tab

The worker owns the socket, parses binary frames, keeps the conflation map, the incremental book and the live candle.

It posts one small batch (latest values only) at most every 16 ms; account messages are forwarded at once.

Pros

  • The main thread only applies small batches and renders.
  • Binary parsing and book sorting never block input.

Cons

  • One extra file and a message protocol.
  • Each tab still opens its own connection.

One shared connection for all tabs

A SharedWorker, or a leader tab chosen with Web Locks, owns the socket and fans data out to other tabs.

Pros

  • One connection per user, however many tabs.
  • Useful when the broker limits connections: Kite's public API allows 3 per API key, each with up to 3,000 instruments.

Cons

  • More moving parts: leader election, a tab closing, browser support differences. The multi-tab-sync guide covers the patterns.

What we pick: Use a dedicated worker per tab. It removes the real cost (parsing and book work at the open) from the main thread with little risk. Move to a shared connection only if users routinely keep many terminal tabs open and the connection count becomes a cost.

PartResponsibilityWhy the boundary is here
Feed workerSocket, binary parsing, subscription modes, conflation, incremental books, live candles, watchdogAll high-rate work in one place, off the main thread
Subscription managerReference-counts which regions want which instrument in which mode; sends batched subscribe and mode messagesComponents declare needs; only one place talks to the socket
Market storeLatest quote per instrument; per-instrument listeners so one row re-renders, not the tableRead with useSyncExternalStore and a selector per row
Account storeOrders, positions, holdings, funds; merges stream events and REST snapshots with the mergeOrder rulesLossless data with its own reconciliation rules
Session storeMarket state per segment, server clock offset, connection stateOne answer to "is this live and tradable now"
Order serviceValidation, risk confirmations, client order ids, submit, retry, unknown-outcome handlingThe only code that can place orders; easy to audit and test
Indicator workerIndicators over long historiesBursty CPU work that must not block input
UI regionsWatchlist, chart, depth, order form, orders, positions; layout in the URL and local storageEach region subscribes only to what it renders
Architecture. The main thread holds the UI regions (watchlist, chart, depth, orders, positions) on top, and below them three parts: the market store, the account store and the order service. A workers zone holds an indicators worker and the feed worker, which owns the socket and does parsing, conflation, books and candles. The feed worker sends batches every 16 ms up to the market store and forwards order updates to the account store. Both stores feed the UI regions. The UI sends Buy and Sell to the order service, which posts orders with a key to the broker API in the server zone. The broker API and the feed worker are joined by the socket, which carries binary quotes and JSON order updates.Main threadUI regionswatchlist, chart, depth, orders, positionsMarket storelatest per tokenAccount storemergeOrder rulesOrder servicevalidate, key, retryWorkersIndicatorsfor the chartFeed workerparse, conflate, books16 msordersServerBroker APIREST + socketPOSTone socket: binary quotes and JSON order updates
Market data and account data meet only in the UI. The feed worker owns the socket for both, but routes them to separate stores with opposite rules: conflate quotes, never drop an order update.

Follow one price from the exchange to a pixel. A trade at NSE reaches the broker's market data servers, which send an 8-byte packet to every client subscribed in ltp mode. The worker parses it, feeds the candle builder, and overwrites the conflation map. Within 16 ms it posts a batch, and the market store updates the RELIANCE entry and notifies only the RELIANCE listeners.

Now follow one order back. The order form calls the order service, which posts to the broker API with a client order id. The result returns as order updates on the socket and lands in the account store.

Data flow. Top row, left to right: the exchange sends trades to the broker feed, which sends binary packets over the WebSocket to the feed worker, which sends one batch per frame to the market store. The market store notifies the UI regions per row. The feed worker also forwards order updates to the account store, which feeds the UI regions. Bottom row, right to left: the UI sends Buy or Sell to the order service, which posts the order with an idempotency key to the broker OMS, which sends a new order to the exchange. The OMS sends order updates back up through the broker feed socket.tradesbinarybatchNSEBroker feedWebSocketFeed workerparse, candlesMarket storelatest valuesAccount storeorders, positionsUI regionsrows, chart, tabsper rowordersBroker OMSrisk, routingOrder servicekey, retryB / Snew orderupdatesPOST with an Idempotency-Key
Reads flow along the top, writes along the bottom, and order updates come back up the same socket as prices. Every arrow into a store also has a REST twin (GET /api/orders, quote snapshots) used on load and after reconnects.

Think first

Why do quotes and order updates share one socket, but go to different stores?

Data model: where each piece of state lives

The entities fall into the same two groups. Market data is keyed by a numeric instrument token (Kite uses instrument_token; it is compact in binary packets). Account data is keyed by order id and client order id. Money is always an integer in the smallest unit plus a currency code.

ts
type Instrument = {
  token: number; symbol: string; exchange: "NSE" | "BSE" | "NFO" | "NASDAQ";
  tickPaise: number; lotSize: number; currency: "INR" | "USD"; segment: string;
};
type Quote = {
  token: number; ltpPaise: number; prevClosePaise: number;
  lastTradeAt?: number; receivedAt: number; mode: "ltp" | "quote" | "full";
  depth?: { bids: Level[]; asks: Level[] }; lowerPaise?: number; upperPaise?: number;
};
type Level = { pricePaise: number; qty: number; orders: number };
type Candle = { start: number; o: number; h: number; l: number; c: number; v: number };

type OrderStatus = "SENDING" | "UNKNOWN" | "PENDING" | "OPEN" | "TRIGGER_PENDING"
  | "MODIFY_PENDING" | "CANCEL_PENDING" | "COMPLETE" | "CANCELLED" | "REJECTED";
type Order = {
  clientOrderId: string; orderId?: string; token: number; side: "BUY" | "SELL";
  type: "MARKET" | "LIMIT" | "SL" | "SL-M"; product: "CNC" | "MIS" | "NRML";
  qty: number; filledQty: number; avgPricePaise?: number; pricePaise?: number;
  triggerPaise?: number; status: OrderStatus; reason?: string; exchangeUpdatedAt?: number;
};
type Position = { token: number; product: string; netQty: number; avgPaise: number; realisedPaise: number };
type Session = { segment: string; state: "PRE_OPEN" | "OPEN" | "CLOSING" | "CLOSED" | "HALTED"; nextChangeAt: number };
Client-side types. Prices are integers in paise or cents; times are epoch milliseconds from the exchange where available.
StateLives inWhy
Instrument master (tens of thousands of rows)IndexedDB, refreshed once a daySearch and validation rules without a request; the list changes daily for F&O expiries
Quotes, depth, live candlesFeed worker plus market store (memory)High rate, rebuilt on every load; never persisted
Candle historyMemory cache per instrument and intervalLarge; refetch is cheap; old intervals are evicted
Orders, positions, holdings, fundsAccount store, seeded by REST, updated by the streamLossless; server is the source of truth
Pending client order idssessionStorage until resolvedSurvives a reload during an unknown outcome so the client can look the order up
WatchlistsServer, cached in memoryShared with the mobile app
Layout, open instrument, intervalURL plus local storageShareable and restored on reload

Two details matter in an interview. First, the client adds two order statuses the broker does not have: SENDING (the request is in flight) and UNKNOWN (it timed out). They exist only on the client and resolve to a real status. Second, the instrument master is big (the full Kite instrument list is a CSV with tens of thousands of rows), so search it in a worker and never ship it in the JavaScript bundle.

Interface: REST for state, the socket for change

REST answers "what is true now" and is used on load, after reconnects and as a fallback. The socket answers "what just changed". Every socket stream has a REST twin, so the client can always rebuild from scratch: that rule is what makes reconnects safe.

http
POST /api/orders
Idempotency-Key: 7f3c9a2e-5b1d-4e8a-9c0f-2a6d1e4b8f70
Content-Type: application/json

{ "token": 738561, "side": "BUY", "type": "LIMIT", "product": "CNC",
  "qty": 10, "pricePaise": 141250, "variety": "regular" }

HTTP/1.1 201 Created
{ "orderId": "250114000123456", "clientOrderId": "7f3c9a2e-...",
  "status": "PENDING" }

HTTP/1.1 422 Unprocessable Entity
{ "error": { "code": "PRICE_OUT_OF_BAND", "message": "Price 1,560.00 is above the upper circuit of 1,553.75",
  "field": "pricePaise" } }
Placing an order with a client order id. A repeat with the same key returns the same order, with 200 instead of 201. The 422 is what the same order would get with a price above today's band.
CallPurposeNotes
GET /api/instruments?date=...Daily instrument masterLarge; cached by date; gzip or a compact binary format
GET /api/quotes?tokens=...Snapshot quotes for rows scrolling into viewGives previous close for change %, shown until the first live tick
GET /api/candles/:token?interval=1m&to=...&limit=1000History pages for the chartCursor by time; the last candle may still change
GET /api/orders, /api/positions, /api/holdingsAccount snapshotsOn load, after reconnect, after fills; polled while the socket is down
PUT /api/orders/:id, DELETE /api/orders/:idModify and cancelReturn the pending state; the outcome arrives as an update
GET /api/sessionMarket state per segment, server timeAlso pushed on the socket when it changes
json
// client -> server
{ "a": "subscribe", "v": [738561, 256265] }
{ "a": "mode", "v": ["full", [738561]] }
{ "a": "unsubscribe", "v": [256265] }

// server -> client (text frames)
{ "type": "order", "data": { "orderId": "250114000123456", "clientOrderId": "7f3c9a2e-...",
  "status": "OPEN", "filledQty": 4, "avgPricePaise": 141230, "exchangeUpdatedAt": 1736830512345 } }
{ "type": "session", "data": { "segment": "NSE_EQ", "state": "HALTED", "nextChangeAt": null } }
{ "type": "error", "data": "Session expired" }
Socket messages. Client requests and account updates are JSON text; market data is binary.

Error responses carry a machine code, a human message and the field, so the order form can put the message next to the right input. Rejections that happen later (the exchange rejects an order after the broker accepted it) arrive as an order update with status REJECTED and a reason, which the order row shows in full: "Rejected: insufficient margin, required ₹95,417.84" is useful; "Rejected" is not.

Reliability, trust and security

A trading terminal earns trust on bad days: a flaky network, a broker restart at 09:14, an exchange halt. The rules from earlier chapters add up to a short list of behaviours the app must keep when things go wrong.

Reconnect, resubscribe, resync

  1. Back off with jitter. Retry after about 1, 2, 4, 8 seconds, capped at 30, each with a random spread. If a broker's feed servers restart at the open, a million clients reconnecting on the same second would knock them over again. The retries-and-backoff lesson covers the maths.
  2. Resubscribe from the manager, not from memory of the socket. The subscription manager knows what each region wants; on open it sends one batched subscribe and mode message per mode.
  3. Resync everything incremental. Rebuild order books from snapshots, refetch the last two candles, refetch orders and positions, and merge them with the mergeOrder rules.
  4. Only then go live. The connection state moves from resyncing to live, the banner disappears and prices regain colour.

While the socket is down, the account pipeline falls back to polling: GET /api/orders every 3 seconds while any order is open or unknown, positions every 10 seconds. Orders keep moving at the exchange whether or not our socket is up; the user must still see fills. Nothing is ever shown as filled until the server says so.

Show latency honestly. A small "Live, 42 ms" next to the connection dot, measured as the time between the exchange timestamp and receipt (corrected by the server clock offset) or by an application ping, tells power users when the feed is slow. Treat it as an estimate and round it.

An audit trail of what the user saw

The broker's servers keep the legal record of orders. The client adds what only it knows: for every order action, the client order id, the time of the click, the price on screen and its age, the connection state, and whether a confirmation was shown. Sent with the order as metadata or to the logging pipeline, this settles support disputes such as "the screen showed ₹1,520 when I clicked" in minutes.

Sessions, tabs and frames

  • Two-factor login. Indian brokers ask for a second factor at login, such as a TOTP code from an authenticator app; the auth-flow guide covers the flow.
  • A session that ends daily. Kite's API docs say its access token expires at 6 AM the next day, citing a regulatory requirement, and other Indian brokers' APIs, such as Upstox's, also expire tokens daily. The app treats the end of a session as a normal event: close the socket, freeze the screen with a clear "Session ended, log in again" message, and drop any order draft that was not sent.
  • No orders from stale tabs. A tab left open since yesterday, or one where the user logged out in another tab, must not place orders. Broadcast logout to all tabs with BroadcastChannel (the multi-tab-sync guide and cross-tab-sync lesson cover it), check the session when a tab becomes visible, and rely on the server rejecting expired sessions as the final guard.
  • Tokens out of reach of scripts. Keep the session in an HttpOnly, Secure, SameSite cookie, and protect order requests against CSRF (the auth-tokens lesson compares the options).
  • Never inside a frame. One-click trading is a perfect clickjacking target. Send Content-Security-Policy: frame-ancestors 'none' so no other site can frame the terminal under a fake button.

Check your understanding · Exercise 10

The socket has been down for 40 seconds and the user has an open limit order. Which client behaviours are right? Select all that apply.

Accessibility and performance at the open

Traders are power users. Many never touch the mouse: Kite's web terminal, for example, lets you move through the watchlist with the arrow keys and press B to buy or S to sell the selected instrument. Good keyboard support is a feature here, and it happens to be the same work that makes the terminal usable for screen reader and keyboard-only users.

Keyboard trading

  • The watchlist is a grid. Use the grid pattern from the WAI-ARIA APG: one tab stop, arrow keys move between rows, and a roving tabindex keeps focus on the selected row as prices update (the keyboard-navigation and aria-patterns lessons cover it).
  • Single-key shortcuts only where safe. B and S open the order form; they never submit. WCAG 2.1.4 asks that single-character shortcuts can be turned off or remapped, or work only when the relevant part has focus, because speech-input users trigger them by accident. Scope them to the focused watchlist or chart.
  • The order form traps focus while open, starts on quantity, submits with Enter, closes with Escape, and returns focus to the row it came from.

Up, down and flashing

  • Colour is never the only signal. About 1 in 12 men have some form of colour vision deficiency, and red-green is the most common. Every change shows a sign and an arrow (+1.24% with an up triangle), and P&L shows a plus or minus. Let users swap or pick colours: some markets use red for up.
  • Reduced motion. Under prefers-reduced-motion: reduce, replace cell flashes with a still change of weight or a brief underline, or nothing. A full watchlist flashing at the open is a lot of motion.
  • Screen readers get summaries, not ticks. A live region that reads every price change is unusable. Put prices in normal cells that are read when focused, and announce only events the user asked for: order updates ("Buy 10 RELIANCE: complete at ₹1,412.30") in a polite live region, and connection loss in an assertive one.

A performance budget for 09:15

The INP target of 200 ms (from Core Web Vitals) is easy to meet at 14:00 and hard at 09:15. Measure at the open, not on a quiet afternoon. The budget below is our design target, not a published standard.

Work per frame at the openBudgetHow we stay inside it
Apply market batch on the main threadUnder 2 msOnly latest values for subscribed tokens; per-row listeners
Re-render watchlist rowsUnder 4 msOnly changed rows; no table-wide state; memoised cells
Depth ladderUnder 2 msFixed rows, text updates and transform bars, no layout
ChartUnder 3 msCanvas series.update of the last bar only
Total main-thread workUnder 12 ms of a 16.7 ms frameEverything else lives in the feed and indicator workers

Measure it in the field: record long tasks and INP with real-user monitoring, tagged by time of day, so a regression that only shows up at the open is visible. The runtime-performance lesson covers the profiling side.

Running it in the interview

This problem is large. In 60 minutes you can't cover it all, so show judgement about what is hard and go deep on two or three parts.

Draw the wireframe first and the two pipelines second. Every later answer points at one of those two drawings, which keeps the conversation concrete and saves time. A plan that works:

  1. 5 minutes, scope. Ask the questions from the requirements chapter: markets, depth type, order types, devices. Sketch the wireframe so you both point at the same panels.
  2. 5 minutes, the split. Say that market data is lossy and replaceable and account data is lossless and reconciled. Draw the two pipelines.
  3. 10 minutes, market data. Subscriptions by visibility and mode, binary packets, conflation, freshness and the connection state machine.
  4. 15 minutes, orders. Validation from instrument data, client order ids, the unknown outcome, the order state machine, the cancel race.
  5. 10 minutes, one deep dive the interviewer picks. Usually the incremental order book or the live candle.
  6. 5 minutes, the rest. Positions and P&L in integer paise, market sessions from the server, daily session expiry, accessibility.

Think first

Follow-up: "A user puts 1,000 instruments in one watchlist. What changes?"

Think first

Follow-up: "How would you know the app is showing prices late, in production?"

Think first

Follow-up: "Now support a 24/7 crypto exchange with full depth. What changes?"

Summary

  • Split the data in two: market data is lossy and replaceable (conflate it), account data is lossless (apply every event and reconcile with REST).
  • Subscribe to what is on screen, in the smallest mode each region needs; Kite's ltp packet is 8 bytes, full with depth is 184.
  • A price without an age is a lie: a watchdog on received bytes, a connection state machine, and greyed prices while stale or resyncing.
  • Incremental order books need sequence ids, a buffered snapshot sync, gap detection and resync; snapshot depth can simply be replaced.
  • Build live candles from every tick, bucketed by exchange time and aligned to the session open; the server's candle wins on reconnect.
  • One client order id per intent makes double clicks, retries and lost replies map to one order; a timeout means unknown, never failed.
  • The exchange drives the order state machine: terminal states are final, fills only grow, and cancels are never shown before the server confirms.
  • Money in integer paise or cents, formatted with Intl.NumberFormat; client P&L moves between server fetches, the server stays the truth.
  • Market sessions, halts and holidays are server data; disabled actions always say why.

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 5

References

Concepts behind it

Try next