Design problems
Payments & fintechHardSDE-2 35 min

Design a stock trading platform (Zerodha Kite)

Asked at
Goldman SachsGrowwPaytmZerodha

The interviewer asks

“Design a retail stock broking platform like Zerodha Kite. Users watch live prices, place buy and sell orders on NSE and BSE, and see their orders, positions and holdings update in real time.”

What a strong answer looks like

A strong answer separates three very different problems. First, market data: a few thousand instruments ticking many times a second must fan out to millions of WebSocket clients, so you use a ticker service that holds the latest snapshot per instrument and pushes conflated binary updates. Second, the order path: every order goes through an Order Management System (OMS) and a Risk Management System (RMS) that checks margin atomically, then to the exchange gateway over leased lines; the exchange is the source of truth for order state, so the broker stores what it sent and what the exchange replied, and reconciles. Third, the post-trade side: positions, holdings and funds are derived from exchange trade confirmations and the end-of-day files from the clearing corporation and depository. The hard parts are the 9:15 AM market-open spike, margin checks without double spending, and staying correct when the exchange connection drops mid-order.

Concepts to mention

Order management system and risk management system splitAtomic margin block before sending the orderIdempotency key on order placementExchange as source of truth; UNKNOWN state on timeoutSequence numbers and replay on exchange reconnectTick conflation and snapshot plus deltasBinary WebSocket protocol for market dataEnd-of-day reconciliation with exchange and clearing filesScheduled pre-scaling for market openKafka for post-trade events (positions, notifications)

1 Requirements

Functional

  • Stream live prices (LTP, best bid/ask, depth) for instruments on a user's watchlist
  • Place, modify and cancel orders: market, limit, stop-loss; intraday (MIS) and delivery (CNC) products
  • Pre-trade risk check: block orders that exceed available margin or break exchange rules
  • Show order status updates in real time: open, partially filled, complete, rejected, cancelled
  • Show positions (today's trades with live P&L), holdings (demat shares) and funds
  • Good-Till-Triggered (GTT) orders that fire when a price condition is met, even days later
  • Order history and trade book for the day; contract notes after market close

Non-functional

  • Order placement p99 under 50 ms inside the broker (client to exchange gateway), excluding exchange time
  • Tick delivery under 200 ms from exchange feed to the user's screen at p99
  • 99.99% availability during market hours (9:00 AM to 3:30 PM IST); planned maintenance only after hours
  • Peak of ~20k orders/s in the first minute after 9:15 AM open; ~15M orders on a busy day
  • ~2M concurrent WebSocket connections at market open
  • Zero tolerance for margin double spend or lost orders; full audit trail kept for years (SEBI record rules)
  • Data stored in India (SEBI and RBI rules for regulated entities)

Out of scope (say it out loud)

  • The exchange's own matching engine (separate design)
  • Mutual funds, IPO applications and bonds
  • Charting library and technical indicators on the client
  • Account opening and KYC
  • Algo trading API rate limits beyond a mention

2 Estimates

Concurrent users at open~2M WebSocket connectionsAssume ~8M daily active traders; about 25% are connected in the first 15 minutes after open
Orders per day~15M orders/dayAssume ~3M users place orders, ~5 orders each on average (intraday and F&O traders place many more)
Order QPS~650/s average, ~20k/s peak15M ÷ 22,500 s of trading (6.25 h) ≈ 670/s; the first minute after 9:15 and expiry-day afternoons are 20-30x average
Tick input~100k-500k updates/s~10k actively traded instruments (equity + F&O); liquid F&O contracts change many times per second
Tick output~5-10M messages/s2M clients × ~50 subscribed instruments × conflated to ~1 update/s each (most instruments do not change every second) ≈ 5-10M/s
Order storage~15 GB/day, ~4 TB/year15M orders × ~5 state events each × ~200 bytes ≈ 15 GB/day; kept 8+ years for audit
Market data bandwidth~1-2 Gbps out at peak~7M msgs/s × ~25-byte binary packet ≈ 175 MB/s ≈ 1.4 Gbps, spread across ticker nodes

3 API

EndpointWhat it does
WS/ws/tickerSubscribe/unsubscribe instrument tokens with a mode (ltp, quote, full depth); server pushes binary tick packets
POST/orders/{variety}Place an order (regular, amo, gtt-triggered). Body: instrument, side, qty, price, order_type, product. Header Idempotency-Key
PUT/orders/{order_id}Modify price or quantity of an open order
DELETE/orders/{order_id}Cancel an open order
GET/ordersToday's order book with latest status per order
GET/portfolio/positionsNet and day positions with average price and realised/unrealised P&L
GET/portfolio/holdingsDemat holdings with T1 (bought, not yet settled) quantities
GET/user/marginsAvailable cash, used margin, collateral and payin
POST/gtt/triggersCreate a GTT: condition (instrument, trigger price) plus the order to place when it fires

4 Data model

ordersPostgres (sharded by user_id)
order_id (PK)user_idexchange_order_idinstrument_tokensideqtyfilled_qtypricetrigger_priceorder_typeproductstatusidempotency_key (unique per user)placed_atupdated_at

Strong consistency and transactions are needed for order + margin block. Shard by user_id so a user's order book is one shard. Unique (user_id, idempotency_key) stops double placement on client retry.

order_eventsPostgres append-only table, archived to S3
order_idseqevent (sent, ack, partial_fill, fill, reject, cancel)exchange_tspayload

Every message to and from the exchange, in order. This is the audit trail and the input for reconciliation. Moved to S3/Parquet after the day closes.

margin_ledgerPostgres (same shard as orders)
user_identry_idtype (block, release, payin, payout, mtm)amountref_order_idcreated_at; plus balances(user_id PK, available, blocked, version)

Double-entry style. Block margin in the same transaction that creates the order row, so you can never send an order without blocking money.

positionsRedis (hot) + Postgres (durable)
user_idinstrument_tokenproductnet_qtybuy_qtysell_qtybuy_valuesell_valueavg_price

Updated from trade confirmations. Redis hash per user for fast reads; Postgres is the record. Live P&L is computed on read with the latest LTP.

holdingsPostgres
user_idisinqtyt1_qtyavg_costpledged_qty

Rebuilt every night from depository (CDSL/NSDL) and clearing files; intraday changes come from settled sells and T1 buys.

instrument_snapshotIn-memory on ticker nodes (+ Redis copy)
instrument_tokenltpvolumeohlcbest 5 bid/askoilast_exchange_ts

Latest state per instrument. New subscribers get the snapshot first, then deltas.

gtt_triggersPostgres + in-memory index on trigger service
gtt_iduser_idinstrument_tokencondition (above/below)trigger_priceorder_templatestatusexpires_at

Trigger service keeps a per-instrument sorted index of trigger prices to check each tick in O(log n).

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALplace ordersubscribe tickscheck + block marginledger txnorder over leased lineack / fill / rejectorder eventstradesorder updatesmulticast ticksconflated ticksprice streamfire orderarchiveEOD filesholdings, fundsKite web / mobile appLB / API gatewayTicker WebSocket edgeRisk & margin (RMS)Order service (OMS)Portfolio serviceMarket data handlerEOD reconciliationGTT trigger engineKafka (orders, trades)Postgres (orders,ledger)Redis (positions, LTP)S3 audit archiveClearing corp /depositoryExchange gateway(NSE/BSE)Exchange market feed
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Place a limit buy order

  1. 1App sends POST /orders/regular with an Idempotency-Key; the gateway authenticates the session and applies per-user rate limits.
  2. 2OMS validates the order: instrument is tradable, price is inside the day's circuit band and a multiple of tick size, quantity is a multiple of lot size.
  3. 3OMS calls RMS. In one Postgres transaction on the user's shard, RMS computes required margin (full value for CNC, SPAN + exposure for F&O, leverage-based for MIS), checks available >= required, writes a 'block' ledger entry and inserts the order row with status PENDING_SEND.
  4. 4If the key already exists, OMS returns the earlier order instead of creating a new one.
  5. 5OMS hands the order to the exchange gateway session that owns this user's partition; the gateway assigns a sequence number, logs it, and sends it over the leased line.
  6. 6OMS returns order_id to the app immediately with status 'open pending'; the app does not wait for the exchange.
  7. 7The exchange acknowledges with an exchange_order_id; OMS writes an order_event and updates status to OPEN.
  8. 8On each fill, the exchange sends a trade confirmation; OMS updates filled_qty and publishes a trade event to Kafka.
  9. 9Portfolio service consumes the trade, updates the position in Redis and Postgres, and RMS converts blocked margin to used margin.
  10. 10The WebSocket edge pushes the order update to the user's open connections, so the order book and positions refresh without polling.

Stream live prices

  1. 1Market data handlers receive the exchange multicast feed in a co-located or leased-line data centre, with A and B redundant streams, and rebuild each instrument's state.
  2. 2Handlers publish normalised updates to ticker nodes, sharded by instrument token.
  3. 3Each ticker node keeps the latest snapshot per instrument and, every ~250 ms-1 s, collects which instruments changed (conflation).
  4. 4The app opens a WebSocket and subscribes to instrument tokens with a mode; the edge sends the current snapshot first, then only changes.
  5. 5Updates go out as compact binary packets (a few dozen bytes per instrument), batched per connection to cut syscalls and bandwidth.

GTT order fires

  1. 1User creates a GTT: 'sell 50 INFY if price falls below ₹1,400'. It is stored in Postgres and loaded into the trigger engine's in-memory index for that instrument.
  2. 2The trigger engine consumes the tick stream; for each INFY tick it checks the sorted 'below' list for trigger prices >= LTP.
  3. 3For each hit it marks the GTT TRIGGERED with a conditional update (status = ACTIVE → TRIGGERED), so a restart or duplicate tick cannot fire it twice.
  4. 4It calls OMS with the stored order template and an idempotency key = gtt_id; the order then follows the normal path, including a fresh margin check.

7 Deep dives

Margin check without double spending

The classic bug: a user with ₹10,000 sends two ₹8,000 orders at the same moment from two devices, and both pass the check. The fix is to make 'check available margin, block it, create the order' one atomic step on one shard. Shard orders and the margin ledger by user_id so they live in the same Postgres database. In one transaction, run SELECT ... FOR UPDATE on the user's balance row (or an optimistic update: UPDATE balances SET blocked = blocked + 8000, version = version + 1 WHERE user_id = ? AND available - 8000 >= 0 AND version = ?), insert the ledger 'block' entry, and insert the order with status PENDING_SEND. Only after commit is the order sent to the exchange. Per-user row locks are fine because one user rarely sends more than a few orders a second, so there is no hot row across users. When the order is rejected or cancelled, a 'release' entry is written in the same way. Margin for F&O is harder: SPAN margin depends on the whole portfolio, and hedged positions need less margin. Precompute the user's current portfolio margin in RMS memory, and calculate the incremental margin for the new order; refresh SPAN parameter files that the clearing corporation publishes several times a day. Interviewers want to hear 'atomic block before send' and 'per-user serialisation', not a distributed lock across services.

The exchange is the source of truth: handling unknown order state

The dangerous case is a timeout: OMS sent the order, the leased line dropped, and no ack arrived. Did the exchange accept it? If you mark it failed and the user retries, they may end up with two positions. If you assume success, money stays blocked. So the order goes to an UNKNOWN state, never to 'rejected'. Each exchange session uses sequence numbers; on reconnect, the gateway replays from the last sequence it confirmed and the exchange resends missed messages, which resolves most unknowns within seconds. The broker also sends a unique client order reference with each order, so it can query the exchange's order status by that reference. The UI shows 'status being confirmed' and blocks a duplicate order with the same details for a short window. Run each exchange session from a primary and a hot standby gateway; only one is logged in at a time because exchanges allow a set number of sessions per trading member. At end of day, compare the broker's order book and trade book with the exchange trade file and the clearing obligation file. Any mismatch is a break that ops must fix before next market open. Saying this clearly shows you understand that external systems are authoritative.

Fanning out ticks to 2M connections

Ticks are high volume and only the latest value matters, so this is a pub/sub problem with conflation, not a queue problem. Do not put each tick through Kafka per user. Instead, market data handlers push updates to ticker nodes that each hold the full latest snapshot in memory (10k instruments × ~200 bytes is only a few MB). Each WebSocket edge node serves ~50k-100k connections and keeps, per instrument, the set of connections subscribed. Every 250 ms-1 s it walks the instruments that changed and writes one batched binary frame per connection. Conflation means a slow client on a 3G network in a Tier-3 town gets the latest price, not a growing backlog; if its send buffer stays full, drop intermediate updates and finally disconnect it. Use a compact binary format: a token, then fixed-width integers for price in paise, so a full-mode packet is a few dozen bytes. Separate the order update channel from ticks on the same socket with message type, and give order updates priority, since a missed fill notice is much worse than a missed tick. At 9:15 AM, millions reconnect at once; protect the edge with connection rate limits, jittered client reconnects and pre-scaled nodes, because autoscaling reacts too slowly for a spike that lasts minutes.

Surviving the 9:15 AM and expiry-day spikes

Traffic is not smooth: login and order load in the first five minutes after open can be 20-30x the daily average, and F&O expiry afternoons spike again. Plan capacity for the peak, not the mean, and scale up before 9:00 AM on a schedule. Pre-warm everything: load instrument master and SPAN files, warm connection pools, and preload each active user's balance and positions into RMS memory before market open. After-market orders (AMO) placed overnight are queued and released to the exchange right at open, so stagger them across gateway sessions and respect each session's exchange message rate limit; a token-bucket per session keeps you from being throttled by the exchange. Keep the order path short and synchronous only up to the gateway; everything else (positions, notifications, analytics) consumes Kafka asynchronously. If a downstream service is slow, the order path must not wait. Finally, shed non-critical load at peak: disable heavy reports and historical chart APIs for a few minutes, and keep order placement and cancellation as the protected path. A cancel must always get through, even when new orders are being rate limited.

8 Trade-offs

Postgres sharded by user vs a NoSQL store for orders

Orders and margin need multi-row transactions (block money and create order together) and strong consistency, which Postgres gives on one shard. Cassandra or DynamoDB would scale writes easily but make the margin check a conditional write puzzle across items. At ~20k orders/s peak, 16-32 Postgres shards with replicas handle the load. Use Kafka and S3 for the high-volume event history, so Postgres holds only the current day's hot data.

Conflated pushes vs every tick

Sending every exchange tick to every client is wasteful and breaks slow clients. Conflating to the latest value every 250 ms-1 s cuts output by 10x or more and is what a human eye can follow. The cost is that clients do not see every intermediate price, which is fine for retail UIs. Algo users who need every tick should use a separate paid feed or co-location, not the retail WebSocket.

Respond on 'sent' vs wait for exchange ack

Waiting for the exchange ack before replying makes the API latency depend on the exchange and on the leased line, and holds a request thread per order at peak. Replying once the order is durably stored and queued for the gateway keeps p99 low; the ack and fills come back over the WebSocket. The UI must then show a clear 'pending' state. Most brokers do this.

Build OMS/RMS in-house vs buy

Many brokers start with a vendor OMS that is already certified by exchanges, because exchange certification and compliance are slow and costly. Building in-house gives control over latency, features like GTT and baskets, and cost at scale. In an interview, mention the choice and say you would build the user-facing services and wrap the vendor gateway behind a clean internal API either way.

9 Bottlenecks

Login and session storms at open

At 10x users, millions of logins and WebSocket reconnects hit in the same minute. Fix: long-lived sessions issued the previous day where allowed, stateless signed tokens validated at the edge, connection admission control with retry-after, and reconnect jitter in the client. Pre-scale edge fleets on a schedule before 9:00 AM.

Exchange message rate limits

Exchanges cap messages per second per trading session. At 10x orders, the broker needs more sessions (more leased lines and exchange approvals) and must spread users across them. A per-session token bucket queues bursts briefly instead of getting throttled or disconnected by the exchange.

Hot instruments on ticker nodes

On expiry day, NIFTY and BANKNIFTY options near the money are subscribed by most users. Sharding ticker nodes by instrument would make one node hot. Instead, replicate the full snapshot on every edge node (it is small) so each node serves its own connections without a central hop.

10 Failure handling

Leased line to the exchange fails

Each exchange session has a backup line and a standby gateway. In-flight orders move to UNKNOWN; on reconnect, sequence-number recovery replays missed acks and fills. New orders are queued for a few seconds or rejected with a clear message. Cancellations get priority once the line is back.

Market data feed gap

Handlers subscribe to both A and B multicast streams and fill gaps from whichever has the packet; if both miss, request retransmission or a snapshot recovery. If prices go stale, the app marks them stale with a timestamp rather than showing old prices as live. The GTT engine pauses triggers on a stale instrument.

Portfolio service lags behind trades

Because positions are built from Kafka events, a slow consumer means positions update late, but the order book (from OMS) is still correct. Kafka keeps the events; the consumer catches up idempotently using (exchange trade_id) as a dedupe key. Margin is updated from the same events, so RMS also relies on trade ids to stay idempotent.

Primary Postgres shard fails mid-session

Synchronous replication to a standby in the same region (with a second region for DR) means no committed order is lost; failover takes seconds. Orders for users on that shard are briefly rejected with a retry message. Never fail over to an async replica without checking for lost writes against the exchange order log.

11 What impresses

  • Separating market data (lossy, latest-value-wins) from order flow (lossless, ordered) and designing each differently
  • Treating an exchange timeout as UNKNOWN and explaining sequence-number recovery instead of blind retries
  • Doing the margin check and order insert in one per-user transaction to prevent double spend
  • Giving cancels priority over new orders under load
  • Mentioning exchange per-session message limits and how sessions are spread across users
  • Knowing that T+1 settlement means bought shares appear as T1 holdings before reaching the demat account

12 Mistakes

  • Putting every tick for every user through Kafka or a database
  • Retrying an order after a timeout without checking whether the exchange already has it
  • Checking margin with a read and then blocking it in a separate step (race condition)
  • Designing for average QPS and ignoring the 9:15 AM spike
  • Claiming the broker matches orders; the exchange matches, the broker routes and manages risk
  • Forgetting reconciliation with exchange and depository files

13 Follow-ups

Q1How do you compute live P&L for a user with 200 open positions without hammering the database?
Q2How would you add basket orders where all legs must be accepted or none?
Q3How do you square off intraday (MIS) positions automatically at 3:20 PM for millions of users?
Q4What changes for an algo-trading API where users send hundreds of orders per second?
Q5How would you support T+0 settlement?
Q6The exchange sends a fill for an order your OMS has no record of. What do you do?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free