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.
| Question | Answer we assume | What it changes |
|---|---|---|
| Which markets? | Indian cash and F&O (NSE, BSE) as the main case, with notes for US stocks and 24/7 crypto | Sessions, 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 exchange | We 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 once | Subscribe 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 pairs | Snapshot-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 orders | The order form, validation, and the order state machine |
| Devices? | Desktop first (the terminal), with a responsive layout for tablets; native apps are separate | Dense 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.
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.
- 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).
- 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).
- 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).
- 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.
- 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.
| Aspect | Market data | Account data |
|---|---|---|
| Examples | Prices, depth, candles | Orders, trades, positions, holdings, funds |
| Volume | Thousands of updates per second at peaks | A few events per minute per user |
| Losing an update | Fine for prices: the next tick replaces it | Never fine: a missed fill means a wrong position |
| Source of truth | The exchange feed, through the broker | The broker's order and position service |
| Client strategy | Conflate, sample per frame, drop old ticks | Apply every event in order, reconcile with a REST fetch |
| When unsure | Grey it out and show its age | Show pending and poll; never guess |
Think first
Where does market depth for a crypto pair fall: lossy like prices, or lossless like orders?
In between. Each depth update is a change to a shared structure (set the quantity at price 101.5 to 40), not a full replacement. If you drop one change, the book is wrong until something fixes it.
So depth is lossless within one stream (apply every change in order, detect gaps with sequence numbers) but lossy for rendering (paint the book at most once per frame). The market depth chapter builds exactly this.
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?
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.
| Mode | Packet size | What it holds | Who uses it here |
|---|---|---|---|
ltp | 8 bytes | Instrument token and last traded price | Watchlist rows, positions |
quote | 44 bytes | Adds last quantity, average price, volume, buy and sell totals, OHLC | The instrument header strip |
full | 184 bytes | Adds timestamps, open interest and 5 levels of bids and asks | The 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.
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;
}
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).
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
| Situation | How you detect it | What the user sees |
|---|---|---|
| Connection stale | No bytes at all (not even a heartbeat) for 5 seconds. Kite sends a 1-byte heartbeat every couple of seconds when there is nothing else | A banner ("Reconnecting, prices paused at 10:02:14"), all live prices greyed, flashing stops |
| Instrument quiet | Connection 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 trading | Session state from the server: pre-open, closed, halted, holiday | A 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
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();
});
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".
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?
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.
| Aspect | Snapshot depth (NSE, Kite full mode) | Incremental depth (crypto, for example Binance diff stream) |
|---|---|---|
| Each message | The full best 5 bids and 5 asks | Only the levels that changed: price and new total quantity |
| Missing a message | Harmless: the next one is complete | The book is wrong until you rebuild it |
| Client work | Replace the 10 rows | Keep a sorted book, apply changes in order, detect gaps, resync |
| Conflation | Keep the last snapshot per frame | Apply 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:
- Open the stream first and buffer its events. Note the
Uof the first one. - Fetch a REST snapshot of the book. It carries
lastUpdateId. If that is lower than the first bufferedU, the snapshot is too old: fetch again. - Drop buffered events whose
uis at or belowlastUpdateId; they are already inside the snapshot. - Apply the remaining buffered events, then live ones. Each event's
Ushould be exactly the previousuplus 1. - If an event's
Uis bigger than your book's id plus 1, you missed something. Throw the book away and start again from step 1. - 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.
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.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));
}
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. Settransform: scaleX(0.42)on a full-width element instead ofwidth: 42%: a transform is composited and does not trigger layout (the reflow-repaint-composite lesson explains why). - Stable numbers. Use
font-variant-numeric: tabular-numsso 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.
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?
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.
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 };
}
- 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".
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
| Order type | What the user gives | What happens | US name |
|---|---|---|---|
| Market | Quantity | Fills now at the best available prices; the price is not known in advance | Market |
| Limit | Quantity, price | Fills only at that price or better; may wait or never fill | Limit |
| SL (stop-loss limit) | Quantity, trigger price, limit price | Waits until the market reaches the trigger, then becomes a limit order | Stop limit |
| SL-M (stop-loss market) | Quantity, trigger price | Waits for the trigger, then becomes a market order | Stop (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.
| Rule | Example | Client check |
|---|---|---|
| Tick size | Price 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 price | Work 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 time | qty % 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 close | Price between lower_circuit_limit and upper_circuit_limit from the quote |
| Trigger relation | A buy SL triggers at or below its limit price; a sell SL at or above | Compare trigger and price for the side |
| Freeze quantity | Exchanges cap quantity per order in F&O | Warn, or let the server split it (Kite has an autoslice option) |
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
}
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?
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.
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.
- Partially filled is not a status of its own in Kite: an
OPENorder withfilled_quantityabove 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,CANCELLEDorREJECTED, no later message moves it back. A lateOPENarriving afterCOMPLETE(from a slow poll, or a reordered message) is ignored. - Fills only grow.
filled_quantitynever 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.
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;
}
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.
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.
| Step | Trade or price | Realised | Unrealised |
|---|---|---|---|
| 1 | Buy 100 INFY at ₹1,500.00 | ₹0.00 | ₹0.00 |
| 2 | Sell 40 at ₹1,520.50 | 40 × 20.50 = ₹820.00 | 60 shares left at avg ₹1,500.00 |
| 3 | LTP is ₹1,512.25 | ₹820.00 | 60 × 12.25 = ₹735.00 |
| 4 | LTP drops to ₹1,495.10 | ₹820.00 | 60 × (−4.90) = −₹294.00 |
Exact money on the client
- Integers, not floats. In JavaScript,
0.1 + 0.2is0.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.78with Indian lakh grouping;en-USandUSDgive$123,456.78. AddsignDisplay: "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.
| Session (NSE equity, IST) | Time | What the UI does |
|---|---|---|
| Pre-open order entry | 09: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 buffer | About 09:10 to 09:15 | Order entry closed; show "Opening at 09:15"; queue nothing silently |
| Normal market | 09:15 to 15:30 (since August 2026, F&O stocks stop continuous trading at 15:15) | Everything live |
| Closing auction and post-close | After continuous trading ends, until 16:00; the exact windows were revised in 2026 | Show the auction state and the indicative closing price; post-close trades happen at the closing price only |
| Closed | After that, weekends, holidays | Orders 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?
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.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Feed worker | Socket, binary parsing, subscription modes, conflation, incremental books, live candles, watchdog | All high-rate work in one place, off the main thread |
| Subscription manager | Reference-counts which regions want which instrument in which mode; sends batched subscribe and mode messages | Components declare needs; only one place talks to the socket |
| Market store | Latest quote per instrument; per-instrument listeners so one row re-renders, not the table | Read with useSyncExternalStore and a selector per row |
| Account store | Orders, positions, holdings, funds; merges stream events and REST snapshots with the mergeOrder rules | Lossless data with its own reconciliation rules |
| Session store | Market state per segment, server clock offset, connection state | One answer to "is this live and tradable now" |
| Order service | Validation, risk confirmations, client order ids, submit, retry, unknown-outcome handling | The only code that can place orders; easy to audit and test |
| Indicator worker | Indicators over long histories | Bursty CPU work that must not block input |
| UI regions | Watchlist, chart, depth, order form, orders, positions; layout in the URL and local storage | Each region subscribes only to what it renders |
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.
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?
One connection means one handshake, one auth check, one reconnect path and one watchdog. Splitting them would double the failure cases for no gain.
But their rules are opposite. Quotes are conflated and dropped freely; order updates must each be applied, in a merge that ignores stale ones. Mixing them in one store invites someone to conflate an order update one day.
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.
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 };
| State | Lives in | Why |
|---|---|---|
| Instrument master (tens of thousands of rows) | IndexedDB, refreshed once a day | Search and validation rules without a request; the list changes daily for F&O expiries |
| Quotes, depth, live candles | Feed worker plus market store (memory) | High rate, rebuilt on every load; never persisted |
| Candle history | Memory cache per instrument and interval | Large; refetch is cheap; old intervals are evicted |
| Orders, positions, holdings, funds | Account store, seeded by REST, updated by the stream | Lossless; server is the source of truth |
| Pending client order ids | sessionStorage until resolved | Survives a reload during an unknown outcome so the client can look the order up |
| Watchlists | Server, cached in memory | Shared with the mobile app |
| Layout, open instrument, interval | URL plus local storage | Shareable 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.
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" } }
| Call | Purpose | Notes |
|---|---|---|
GET /api/instruments?date=... | Daily instrument master | Large; cached by date; gzip or a compact binary format |
GET /api/quotes?tokens=... | Snapshot quotes for rows scrolling into view | Gives previous close for change %, shown until the first live tick |
GET /api/candles/:token?interval=1m&to=...&limit=1000 | History pages for the chart | Cursor by time; the last candle may still change |
GET /api/orders, /api/positions, /api/holdings | Account snapshots | On load, after reconnect, after fills; polled while the socket is down |
PUT /api/orders/:id, DELETE /api/orders/:id | Modify and cancel | Return the pending state; the outcome arrives as an update |
GET /api/session | Market state per segment, server time | Also pushed on the socket when it changes |
// 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" }
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
- 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.
- Resubscribe from the manager, not from memory of the socket. The subscription manager knows what each region wants; on
openit sends one batched subscribe and mode message per mode. - Resync everything incremental. Rebuild order books from snapshots, refetch the last two candles, refetch orders and positions, and merge them with the
mergeOrderrules. - Only then go live. The connection state moves from
resyncingtolive, 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,SameSitecookie, 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
tabindexkeeps focus on the selected row as prices update (the keyboard-navigation and aria-patterns lessons cover it). - Single-key shortcuts only where safe.
BandSopen 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 open | Budget | How we stay inside it |
|---|---|---|
| Apply market batch on the main thread | Under 2 ms | Only latest values for subscribed tokens; per-row listeners |
| Re-render watchlist rows | Under 4 ms | Only changed rows; no table-wide state; memoised cells |
| Depth ladder | Under 2 ms | Fixed rows, text updates and transform bars, no layout |
| Chart | Under 3 ms | Canvas series.update of the last bar only |
| Total main-thread work | Under 12 ms of a 16.7 ms frame | Everything 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:
- 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.
- 5 minutes, the split. Say that market data is lossy and replaceable and account data is lossless and reconciled. Draw the two pipelines.
- 10 minutes, market data. Subscriptions by visibility and mode, binary packets, conflation, freshness and the connection state machine.
- 15 minutes, orders. Validation from instrument data, client order ids, the unknown outcome, the order state machine, the cancel race.
- 10 minutes, one deep dive the interviewer picks. Usually the incremental order book or the live candle.
- 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?"
Little, by design. The list is virtualised, and subscriptions follow visible rows, so the socket still carries about 25 instruments in ltp mode. Sorting by live change % is the catch: it needs prices for all 1,000.
Offer sorting by a server-computed snapshot (refreshed every few seconds) instead of live sorting, or subscribe all 1,000 in ltp mode only while the sort is on. At 8 bytes per tick that is affordable for a short time; say the trade-off out loud.
Think first
Follow-up: "How would you know the app is showing prices late, in production?"
Measure the gap between the exchange timestamp in full packets and the time the frame painted, corrected by the server clock offset. Report percentiles per region and per hour with real-user monitoring.
Alert when the 95th percentile at the open passes a threshold, and break it down by stage: network arrival, worker processing, and main-thread paint, so you know which side to fix.
Think first
Follow-up: "Now support a 24/7 crypto exchange with full depth. What changes?"
Depth becomes incremental, so the book needs sequence ids, buffered snapshot sync, gap detection and resync (the market depth chapter). There is no close: daily candles cut at a fixed UTC time, there are no AMOs, and the session banner mostly disappears.
Quantities become decimals (0.00042 BTC), so integer units become the smallest unit of each asset, with the precision taken from the instrument data rather than assumed to be 2 decimal places.
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
ltppacket is 8 bytes,fullwith 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.
References
- Kite Connect v3: WebSocket streaming Modes, binary packet layout, paise prices, order updates on the same socket
- Kite Connect v3: Orders Order types, products, varieties, statuses and tags
- Kite Connect v3: Exceptions and rate limits Error types, the 403 on an expired session, and order rate limits
- Binance Spot API docs: WebSocket streams Diff depth stream and how to manage a local order book
- Lightweight Charts documentation Series updates and the time scale
- Lightweight Charts: Time zones Why the library has no time zone setting, and how to shift timestamps
- Lightweight Charts on GitHub Licence and attribution requirement
- MDN: WebSocket API
- MDN: Intl.NumberFormat
- WAI-ARIA APG: Grid pattern
- WCAG 2.2: Understanding Character Key Shortcuts
- web.dev: Interaction to Next Paint (INP)
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.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- 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.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.