~learn.

Zerodha Kite Trading Platform, the whole design.

A broker platform that takes up to 40 million orders a day and streams prices to 1.5 million screens, by giving orders, prices and reads three separate paths sized for what each can afford to lose.

7 diagrams · 8 sections · revise in ~10 min · Download PDF prints it in the light theme, diagrams included

SQ-13 · Zerodha Kite Trading Platform · design sheethimanshukukreja.in/learn/sheets/bonus-13-zerodha-kite-trading

At a glance#

Number Value What it drives
Orders a day (design) 15M busy · 40M spike ~670/s average over a 22,500 s session
First minute at 9:15 7,500/s · peak second ~15,000/s 11× average; 40,000/s on a spike day (assumed 3 % share, 2× second)
Edge capacity 50,000 order requests / s 1.25× spike-day peak
Reads at the open ~167,000 / s own read path from memory, apart from orders
Market data 1.5M sockets · ~30M packets / s binary ~12 Gbps vs JSON ~60 Gbps; ~60 ticker servers
RMS state 7M accounts × 2 KB ≈ 14 GB 16 in-memory partitions, ~2,500 checks/s each at peak
Exchange sessions ~90 at 60 % load 18,000 in flight cap (90 × 200 unacked)
Audit ~10 TB / yr · ~80 TB / 8 yr append-only order events, kept 8 years

Architecture#

DiagramKite on one page: three paths behind one edge
%% title: Kite on one page: three paths behind one edge
flowchart TD
  APP@{ icon: "sd:phone", label: "Trading apps · web, Android, iOS<br/>API clients · algos" } ==> GW["API gateway<br/>auth · client bucket 10/s · admission"]:::edge
  GW -->|"reads"| HOT@{ icon: "tech:redis", label: "Hot Redis cache<br/>per-user orders, positions, margins" }
  GW ==>|"place, modify, cancel"| OMS["Order service<br/>state machine · one order per key"]:::core
  OMS ==>|"check + block"| RMS["RMS · 16 in-memory partitions<br/>one thread per partition · journalled"]:::hot
  OMS ==> ADM@{ icon: "sd:queue", label: "Admission queue<br/>cancel · exit · modify · new lanes<br/>deadlines · 18,000 in-flight cap" }
  OMS --> BUS@{ icon: "tech:kafka", label: "Kafka<br/>order, fill, funds events" }
  BUS --> ODB@{ icon: "sd:log", label: "Order + audit store<br/>append-only · 8 years" }
  BUS -.->|"projections"| HOT
  RMS -.-|"posted funds"| LED@{ icon: "sd:database", label: "Funds ledger<br/>double entry · pay-ins, payouts" }
  ADM ==> EGW["Exchange gateways<br/>~90 sessions · leased lines · 2 DCs<br/>mark SENT, then send"]:::service
  EGW ==> EX@{ icon: "sd:building", label: "NSE · BSE · MCX<br/>match, ack, trade" }
  EX --> FH["Feed handlers<br/>~100,000 updates/s at open"]:::service
  FH --> TK["Ticker servers · ~60<br/>latest snapshot · binary · 1 flush/s"]:::core
  TK --> WS@{ icon: "tech:websocket", label: "WebSocket edge · 1.5M sockets<br/>binary ticks + sequenced order updates" }
  class APP client
  class HOT cache
  class ADM,BUS queue
  class ODB,LED store
  class EX external
  class WS edge
DiagramRMS partition: check and block in one step
%% title: RMS partition: check and block in one step
flowchart TD
  ORD(["Order from the order service"]):::client --> ROUTE["Hash client id → 1 of 16 partitions<br/>available = cash + collateral − blocked"]:::service
  ROUTE --> CHK{"required ≤ available?"}:::core
  CHK -->|"yes"| BLK["Block margin<br/>blocked += required"]:::hot
  CHK -->|"no"| REJ(["Reject with both numbers<br/>needs ₹70,000 · has ₹30,000"]):::bad
  BLK --> JRN@{ icon: "sd:log", label: "Journal<br/>fsync batched ~1 ms, before answering" }
  JRN --> OK(["Validated · on to admission"]):::ok
  LED@{ icon: "sd:database", label: "Funds ledger<br/>source of truth for money" } -.->|"posted pay-in event"| ROUTE
  JRN -.-> SB["Standby follows journal<br/>takes over in seconds"]:::service
  class JRN,LED store

Key flows#

DiagramOne order at 9:15:00, tap to fill
%% title: One order at 9:15:00, tap to fill
sequenceDiagram
  autonumber
  participant A as Asha's app
  participant G as Gateway
  participant O as Order service
  participant R as RMS
  participant X as Exchange gateway
  participant E as Exchange
  A->>G: Buy 50, limit · key k7
  G->>O: admitted, bucket has a token
  O->>O: insert once per (client, k7) · RECEIVED
  O->>R: check and block margin
  R-->>O: OK, blocked and journalled
  O-->>A: order 8812 · VALIDATED (~30 ms)
  O-)X: order.send via outbox
  X->>X: mark SENT, then write bytes
  X->>E: new order · broker ref 8812
  E-->>X: accepted · exchange order id
  X-)A: OPEN, pushed over WebSocket
  E-->>X: trade · 50 at limit
  X-)A: COMPLETE · position updated
  Note over A,E: a retry with key k7 just reads order 8812 back
DiagramLine drops before the ack: UNKNOWN, then reconcile
%% title: Line drops before the ack: UNKNOWN, then reconcile
sequenceDiagram
  autonumber
  participant O as Order service
  participant X as Gateway DC-A
  participant E as Exchange
  participant R as Reconciler
  participant A as App
  O->>X: send order 8812
  X->>E: new order · ref 8812
  Note over X,E: line drops before the ack
  X-->>O: no answer in 2 s · UNKNOWN
  O-)A: Sent, confirming. Don't place it again
  Note over O: never resend · never say failed
  R->>E: status of ref 8812 via DC-B
  alt found at the exchange
    E-->>R: open · exchange order id
    R->>O: UNKNOWN → OPEN
  else not found after full resync
    E-->>R: no such order
    R->>O: UNKNOWN → NOT_PLACED · release margin
  end
  O-)A: final state, in plain words
DiagramTwo orders, one rupee: serialised on the partition
%% title: Two orders, one rupee: serialised on the partition
sequenceDiagram
  autonumber
  participant D1 as Device 1 via OMS-a
  participant D2 as Device 2 via OMS-b
  participant P as RMS partition 7
  Note over P: available ₹1,00,000 · one thread
  D1->>P: block ₹70,000 (order 1)
  D2->>P: block ₹70,000 (order 2)
  P-->>D1: OK · available now ₹30,000
  alt ₹50,000 pay-in already posted by the ledger
    P-->>D2: OK · both pass
  else pay-in not posted yet
    P-->>D2: reject · needs ₹70,000, has ₹30,000
    Note over D2: client retries a moment later
  end

Data model#

DiagramCore entities
%% title: Core entities
classDiagram
  class Order {
    +orderId : string
    +clientId : string · partition key
    +idemKey : string · unique with clientId
    +qty : int
    +filled : int
    +state : OrderState
    +exchSeq : int · last exchange msg applied
  }
  class OrderEvent {
    +orderId : string
    +state : string
    +reason : string
    +at : timestamp
  }
  class Account {
    +clientId : string
    +cash : paise
    +collateral : paise · after haircut
    +blocked : paise
    +ledgerSeq : int · last posting applied
  }
  class MarginHold {
    +orderId : string
    +amount : paise
  }
  class LedgerPosting {
    +seq : int
    +clientId : string
    +amount : paise
    +kind : pay-in / payout / settlement
  }
  Order "1" --> "many" OrderEvent : audited as
  Account "1" --> "many" MarginHold : blocks
  MarginHold "1" --> "1" Order : for
  Account "1" --> "many" LedgerPosting : funded by
  Account "1" --> "many" Order : places

Lifecycle#

DiagramOrder state machine
%% title: Order state machine
stateDiagram-v2
  [*] --> RECEIVED: inserted once per key
  RECEIVED --> VALIDATED: margin blocked
  RECEIVED --> REJECTED: RMS says no
  VALIDATED --> SENT: marked, then sent
  VALIDATED --> REJECTED: past queue deadline
  SENT --> OPEN: exchange accepts
  SENT --> REJECTED: exchange rejects
  SENT --> UNKNOWN: no answer in time
  UNKNOWN --> OPEN: reconciled, found
  UNKNOWN --> NOT_PLACED: reconciled, not found
  OPEN --> COMPLETE: filled
  OPEN --> CANCEL_PENDING: cancel sent
  OPEN --> CANCELLED: exchange cancels rest
  CANCEL_PENDING --> CANCELLED: cancel confirmed
  CANCEL_PENDING --> COMPLETE: fill won the race
  COMPLETE --> [*]
  REJECTED --> [*]
  CANCELLED --> [*]
  NOT_PLACED --> [*]
  class COMPLETE ok
  class REJECTED,CANCELLED,NOT_PLACED bad
  class UNKNOWN queue

Decisions#

Decision Chose Over Because
Duplicate taps Key per tap + unique (client, key) Dedupe in the app a retry on any server reads the same row
Send ordering Mark SENT, then send Send, then record worst case is UNKNOWN, never a lost send
No ack UNKNOWN + reconcile Resend or mark failed it may already be at the exchange
Margin check In-memory partition, one thread each DB row lock per order 40,000 checks/s, no double spend
Market data Binary snapshots, coalesced, 1 flush/s JSON per exchange update ~12 Gbps vs ~60; old prices are worthless
Slow sockets Skip flush, close after ~10 s Buffer everything one phone can't grow server memory
Open-bell capacity Pre-warm on a schedule by 8:45 Autoscaling the spike is over before an autoscaler reacts
Overload Priority lanes + queue deadlines One FIFO queue cancels and exits first; no stale-price orders

When it breaks#

Failure What users see Contain it with
Leased line flaps orders confirm on the other DC judge sessions by success + ack latency; reconcile in-flight, never move them
Exchange slows to ~800 ms some "not sent, system busy" Little's law ceiling 22,500/s; deadlines; idempotent retries
Exchange feed freezes (24 Feb 2021) "Prices delayed since 10:08" detect staleness, not just disconnect; market protection
RMS partition crashes at 9:15 that partition's clients wait seconds standby follows the journal
Refresh storm at the open reads slow, orders fine read path from memory; degrade charts, depth, reports first
Ticker server restarts brief price gap jittered reconnect; snapshot on next flush

Say it in the interview#