System Design · Low Latency

Design a Stock Exchange / Trading Platform (Zerodha-Style)

Two systems in one question: the exchange that matches orders in microseconds with a single-threaded, sequenced engine, and the broker that checks risk, routes orders and streams prices to millions of phones at 9:15 AM.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 17 min read
Circuit-board trading floor with candlestick shapes and buy and sell order streams meeting at a central matching engine
TL;DR
  • "Design Zerodha" and "design NSE" are different systems. The broker handles users, risk checks, order routing and price streaming; the exchange runs the order book and matching engine. Clarify which, then cover both at the right depth.
  • The order book: price levels sorted per side, FIFO queues per level, a hash map for O(1) cancels. Matching is price-time priority, trading at the resting order's price.
  • Exchanges run a single-threaded matching engine per instrument partition, fed by a sequencer that stamps every input with a sequence number and journals it. Replicas replay the same log to stay identical: event sourcing at microsecond scale.
  • Low latency comes from no locks, no GC pauses, pre-allocated memory, CPU pinning, kernel bypass networking and colocation, not from adding servers.
  • Market data is a huge fan-out: exchanges multicast, brokers conflate and push over WebSockets per subscribed instrument, with a big spike at the 9:15 AM open.

Trading systems are a favourite at fintech companies because they invert the usual system design instincts. Horizontal scaling, eventual consistency and "just add a cache" don't help a matching engine, where every order must be processed in a single, deterministic sequence within microseconds. At the same time, the broker side is a classic high-scale consumer backend with a brutal daily spike at market open. This guide covers both, with a working order book you can run.

The Broker vs Exchange Split

Investor app (Kite-style web/mobile) | v +---------------------------- BROKER (e.g. Zerodha) ----------------------+ | Auth/2FA -> OMS (orders) -> RMS (margin, limits) -> Exchange gateway | | Positions/holdings, funds ledger, market data fan-out, reports, alerts | +-------------------------------------+-----------------------------------+ | exchange member connection v +---------------------------- EXCHANGE (NSE / BSE) ----------------------+ | Gateways -> risk/validation -> SEQUENCER -> MATCHING ENGINE | | -> trade reports to members, market data multicast, surveillance | +-------------------------------------+----------------------------------+ | trades v Clearing corporation (novation, margins, netting) | v Depositories (NSDL/CDSL demat transfers) + banks (funds)
BrokerExchange
UsersMillions of retail investorsHundreds of member firms (brokers, trading firms)
Key latencyMilliseconds (app → exchange round trip)Microseconds inside the matching engine
Scaling styleHorizontal, stateless services, cachesVertical per partition, single-threaded hot path
Hard problemsRisk checks, 9:15 AM spike, price streaming to millionsDeterminism, fairness, sequencing, failover without losing an order
Regulated bySEBI and the exchangesSEBI

Life of an Order

  1. User places "BUY 10 RELIANCE LIMIT ₹2,500.50" in the app.
  2. Broker OMS validates (instrument, lot size, tick size, price band, market hours), assigns an order ID, persists it as PENDING.
  3. Broker RMS checks margin/funds (₹25,005 plus charges available?), holdings for sells, and per-client limits; blocks the margin.
  4. Exchange gateway sends the order to the exchange over the member connection, respecting the member's message rate limits.
  5. Exchange validates, the sequencer assigns a sequence number and journals it, the matching engine either matches it or rests it in the book, and sends an acknowledgement (OPEN) and any trade confirmations.
  6. Broker updates the order (OPEN → PARTIALLY_FILLED → COMPLETE), positions, and pushes updates to the app.
  7. Post-trade: the clearing corporation becomes the counterparty, and settlement moves shares and money. India moved its equity cash market to T+1 settlement in 2023.

Requirements

Exchange

  • Accept new, modify and cancel orders for thousands of instruments (equities, futures, options).
  • Order types: limit, market, stop-loss (trigger price), IOC and day validity; pre-open call auction at the open.
  • Match with price-time priority; publish trades and market data (top of book, depth, last traded price).
  • Enforce price bands/circuit limits; halt instruments when required.
  • Non-functional: deterministic, fair (first come, first served by sequence), microsecond-level matching latency, zero lost orders, fast failover, full audit trail.

Broker

  • Login with 2FA, place/modify/cancel orders, order book and trade book, positions, holdings, funds.
  • Real-time quotes and market depth for watchlists; charts; GTT (good-till-triggered) orders and price alerts.
  • Non-functional: survive the market-open spike, accurate margin checks, order status consistent with the exchange.

Estimates

Public figures for Indian exchanges and brokers vary year to year and are not broken down at this granularity, so use clearly labelled assumptions:

Exchange (assumed)

  • Peak order messages (new + modify + cancel), dominated by algorithmic traders in derivatives: design for ~1 million messages/s overall at peak.
  • Instruments: a few thousand equities plus a very large number of option contracts. Activity is extremely skewed: index options near the money get a big share of the messages.
  • Journal: 1M msgs/s × ~100 bytes = 100 MB/s, ~2 TB over a 6-hour session. Sequential disk writes handle this.
  • Latency target inside matching: single-digit to tens of microseconds per message.

Broker (assumed)

  • 10M clients, 2M active on a busy day, with a large share logging in between 9:00 and 9:20.
  • Orders: ~10M/day; at the open maybe 20K–50K orders/s for the first minute.
  • Price streaming: 1M concurrent WebSocket connections at peak, each subscribed to ~30 instruments. If each instrument update is conflated to once per second per client, that's ~30M messages/s outbound at ~50 bytes ≈ 1.5 GB/s (~12 Gbps). This is the broker's biggest scaling problem, not order placement.

The Order Book Data Structure

For one instrument, the book has two sides:

ASKS (sellers) -- lowest price is best price qty orders (FIFO) 2501.00 200 [#2 200] 2500.50 150 [#1 100] -> [#3 50] <-- best ask ---------------------------------------- spread = 1.50 2499.00 300 [#4 300] <-- best bid BIDS (buyers) -- highest price is best

Operations and their target complexity:

OperationStructureComplexity
Best bid / best askSorted map's first element (or cached pointer)O(1)
Add order at a priceFind/create level, append to FIFOO(log L) with a tree, O(1) with a price-indexed array (L = number of levels)
Cancel by order IDHash map ID → (level, list node), unlinkO(1)
Match against best levelPop from FIFO frontO(1) per fill
Depth snapshot (top 5 levels)Iterate from bestO(5)

Why not a heap? A priority queue gives you the best price, but cancelling an arbitrary order (most exchange messages are cancels and modifies) is O(n) to find, and you lose the per-level aggregation needed for depth data. The sorted map of levels + FIFO queues + ID hash map is the standard answer. (If you've practised heap problems, this is a good contrast to articulate.)

Production engines often use an array indexed by price tick: prices move in fixed ticks (₹0.05 for many Indian equities) within a daily price band, so a band of a few thousand ticks maps directly to array slots, giving O(1) level access and excellent cache locality.

The Matching Engine

Rules: an incoming buy matches resting asks while the best ask ≤ its limit price (any price for market orders); fills happen at the resting order's price; within a level, earliest first. Any unfilled remainder of a limit order rests in the book; a market or IOC remainder is cancelled.

C++: order book with price-time matching and O(1) cancel
#include <cstdint>
#include <functional>
#include <iostream>
#include <list>
#include <map>
#include <unordered_map>
#include <vector>

enum class Side { BUY, SELL };

struct Order {
    uint64_t id;
    Side side;
    int64_t price;      // in paise; ignored for market orders
    int64_t qty;        // remaining quantity
};

struct Trade { uint64_t buyId, sellId; int64_t price, qty; };

class OrderBook {
    // Bids: highest price first. Asks: lowest price first. FIFO within a level.
    std::map<int64_t, std::list<Order>, std::greater<int64_t>> bids;
    std::map<int64_t, std::list<Order>> asks;
    struct Loc { Side side; int64_t price; std::list<Order>::iterator it; };
    std::unordered_map<uint64_t, Loc> index;     // order id -> position, for O(1) cancel

    template <typename Book>
    void match(Order& in, Book& book, bool isMarket, std::vector<Trade>& out) {
        while (in.qty > 0 && !book.empty()) {
            auto level = book.begin();                       // best opposite price
            bool crosses = isMarket ||
                (in.side == Side::BUY ? level->first <= in.price : level->first >= in.price);
            if (!crosses) break;
            auto& queue = level->second;
            while (in.qty > 0 && !queue.empty()) {
                Order& resting = queue.front();              // time priority
                int64_t q = std::min(in.qty, resting.qty);
                out.push_back(in.side == Side::BUY
                    ? Trade{in.id, resting.id, level->first, q}
                    : Trade{resting.id, in.id, level->first, q}); // at resting price
                in.qty -= q;
                resting.qty -= q;
                if (resting.qty == 0) { index.erase(resting.id); queue.pop_front(); }
            }
            if (queue.empty()) book.erase(level);
        }
    }

    template <typename Book>
    void addResting(const Order& o, Book& book) {
        auto& queue = book[o.price];
        queue.push_back(o);
        index[o.id] = Loc{o.side, o.price, std::prev(queue.end())};
    }

public:
    std::vector<Trade> submitLimit(Order o) {
        std::vector<Trade> trades;
        if (o.side == Side::BUY) { match(o, asks, false, trades); if (o.qty > 0) addResting(o, bids); }
        else                     { match(o, bids, false, trades); if (o.qty > 0) addResting(o, asks); }
        return trades;
    }

    std::vector<Trade> submitMarket(Order o) {   // unfilled remainder is cancelled
        std::vector<Trade> trades;
        if (o.side == Side::BUY) match(o, asks, true, trades);
        else                     match(o, bids, true, trades);
        return trades;
    }

    bool cancel(uint64_t id) {
        auto f = index.find(id);
        if (f == index.end()) return false;
        Loc loc = f->second;
        if (loc.side == Side::BUY) {
            auto lvl = bids.find(loc.price);
            lvl->second.erase(loc.it);
            if (lvl->second.empty()) bids.erase(lvl);
        } else {
            auto lvl = asks.find(loc.price);
            lvl->second.erase(loc.it);
            if (lvl->second.empty()) asks.erase(lvl);
        }
        index.erase(f);
        return true;
    }

    void print() const {
        for (auto it = asks.rbegin(); it != asks.rend(); ++it) {
            int64_t total = 0; for (auto& o : it->second) total += o.qty;
            std::cout << "  ASK " << it->first / 100.0 << " x " << total << "\n";
        }
        for (auto& lvl : bids) {
            int64_t total = 0; for (auto& o : lvl.second) total += o.qty;
            std::cout << "  BID " << lvl.first / 100.0 << " x " << total << "\n";
        }
    }
};

int main() {
    OrderBook book;
    book.submitLimit({1, Side::SELL, 250050, 100});   // sell 100 @ 2500.50
    book.submitLimit({2, Side::SELL, 250100, 200});   // sell 200 @ 2501.00
    book.submitLimit({3, Side::SELL, 250050, 50});    // sell 50  @ 2500.50 (behind #1)
    book.submitLimit({4, Side::BUY,  249900, 300});   // buy 300  @ 2499.00
    book.print();

    auto trades = book.submitLimit({5, Side::BUY, 250100, 250});   // aggressive buy
    for (auto& t : trades)
        std::cout << "TRADE buy#" << t.buyId << " sell#" << t.sellId
                  << " " << t.qty << " @ " << t.price / 100.0 << "\n";
    book.cancel(4);
    book.print();
}
Output
  ASK 2501 x 200
  ASK 2500.5 x 150
  BID 2499 x 300
TRADE buy#5 sell#1 100 @ 2500.5
TRADE buy#5 sell#3 50 @ 2500.5
TRADE buy#5 sell#2 100 @ 2501
  ASK 2501 x 100

Dry run. Order #5 wants 250 at up to ₹2,501.00. The best ask is ₹2,500.50 with #1 (100) then #3 (50) in time order: both fill completely at ₹2,500.50, 150 done. The level empties and is removed. Next best ask ₹2,501.00 ≤ 2,501.00, so #2 fills 100 of its 200, leaving 100 resting. #5 is fully filled and never rests. Cancelling #4 removes the only bid in O(1) via the index. Note prices are integers in paise; floating-point prices in an order book are a correctness bug waiting to happen.

Beyond the basics

  • Modify: reducing quantity usually keeps time priority; changing price or increasing quantity is a cancel + new order (loses priority).
  • Stop-loss orders sit in a separate trigger book keyed by trigger price; when the last traded price crosses the trigger, they're injected into the main book as limit/market orders, in sequence.
  • Self-trade prevention: reject or cancel when a member's buy would match its own sell.
  • Pre-open call auction: collect orders without matching, compute the single equilibrium price that maximises traded volume, match everything at that price, then switch to continuous matching.

The Sequencer and Deterministic Replay

Fairness at an exchange means a single, total order of events: if order A arrived before order B, A gets priority, and everyone can verify it. That's the job of the sequencer.

  1. Gateways receive member messages, validate them and forward to the sequencer.
  2. Sequencer assigns a monotonically increasing sequence number and timestamp to each message and writes it to a journal (durable, replicated) before it is processed.
  3. Matching engine consumes the sequenced stream in order, single-threaded. Its output (acks, trades, book changes) is itself sequenced and published.
  4. Replicas consume the same sequenced input and run identical engine code. Because the engine is deterministic (no wall-clock reads, no randomness, no thread scheduling effects; time comes from the sequenced message), every replica holds an identical book.
  5. Failover: if the primary engine dies, a hot standby that has processed up to sequence N takes over. Nothing is lost because the input was journaled before processing.
  6. Recovery from scratch: load the latest snapshot of the book, replay the journal from the snapshot's sequence number.
💡
This is event sourcingThe journal is the source of truth; the order book is a projection of it. The same idea powers Kafka-based systems at millisecond scale (see designing Kafka), but an exchange can't afford a general-purpose broker on the hot path, so the sequencer and journal are purpose-built. LMAX's published architecture, with its single-threaded business logic and the Disruptor ring buffer, is the well-known reference design for this pattern.

Partitioning

One thread can't handle every instrument at peak. Partition instruments across engine instances (e.g. by symbol), each with its own sequenced stream. Instruments within a partition are processed strictly in order; across partitions there's no ordering requirement, because orders for different instruments never interact. Hot instruments (index options near expiry) get their own partitions. Cross-instrument features (spread orders across two contracts) are the exception and need careful design.

Low-Latency Techniques

TechniqueWhy it helps
Single-threaded hot path per partitionNo locks, no contention, no context switches; caches stay warm
Ring buffers between stages (Disruptor-style)Pre-allocated, lock-free handoff; mechanical sympathy with CPU caches
Pre-allocated object pools, no allocation on hot pathAvoids GC pauses (Java) or allocator latency (C++)
CPU pinning, isolated cores, busy-spin waitingRemoves scheduler jitter; trades CPU for latency
Kernel-bypass networking (e.g. DPDK, specialised NICs)Avoids syscalls and kernel network stack overhead
Binary protocols, fixed-size messagesNo JSON parsing; predictable sizes
Journaling with sequential writes, batched fsync or replication to memory of standbysDurability without per-message disk seeks
ColocationExchanges offer racks near the matching engines; distance is latency
Measure tail latency (p99, p99.9)Averages hide the jitter that matters

Note what's missing: microservices, service meshes, Kafka, and databases on the matching path. Each adds milliseconds; the engine budget is microseconds. A strong interview answer explicitly says which "standard" tools you are not using and why.

Market Data Fan-Out

At the exchange

The engine's output is turned into market data: trades, top-of-book quotes, market depth and periodic snapshots. Exchanges typically distribute it via UDP multicast to members (one send reaches all subscribers on the network), with sequence numbers so receivers detect gaps, plus a TCP recovery/retransmission service and periodic snapshots so a late joiner can rebuild state.

At the broker: millions of phones

  1. Feed handlers consume the exchange feed, normalise it and maintain the latest state per instrument (LTP, OHLC, volume, depth) in memory.
  2. Distribution tier: the latest state is published to a fleet of WebSocket servers. Each server keeps, per instrument, the set of connected clients subscribed to it.
  3. Conflation: a retail client doesn't need every tick. Each server sends the latest snapshot per subscribed instrument at a fixed interval (e.g. every second), coalescing intermediate ticks. This caps bandwidth regardless of market activity.
  4. Modes: LTP-only (tiny), quote, and full depth, chosen per subscription, so a watchlist of 50 doesn't pull 50 depth books.
  5. Binary encoding rather than JSON for ticks; the savings matter at millions of messages per second.

WebSockets fit because the stream is long-lived, bidirectional (subscribe/unsubscribe messages) and high-frequency; compare the transports in WebSockets vs SSE vs long polling. At 9:15 the connection storm is as dangerous as the message volume: pre-scale WebSocket servers, spread reconnects with jittered backoff, and keep the subscribe path cheap.

Broker Design: OMS, RMS and the Exchange Gateway

App --> API GW --> OMS -----> RMS ------> Exchange Gateway ==> NSE / BSE | ^ | | ^ | | v | | acks, trades | | Margin/funds | | v | ledger (SQL) v | Orders DB <---- order updates (sequenced per order) ----+ | v Positions & holdings service --> push to app (WebSocket)
  • OMS (Order Management System): owns the order state machine (VALIDATION_PENDING → OPEN_PENDING → OPEN → PARTIALLY_FILLED → COMPLETE / CANCELLED / REJECTED). Every exchange response is applied idempotently by exchange order ID and update sequence.
  • RMS (Risk Management System): pre-trade checks must be fast and correct: available margin (including SEBI-mandated margin rules for derivatives), short-sell restrictions, quantity freeze limits, price bands, per-client and per-instrument exposure limits. Margin is blocked atomically when the order is placed and released on cancel/reject. Keep each client's margin state in memory on a partitioned RMS service (partitioned by client ID) backed by a durable ledger, so checks are microseconds-to-milliseconds, not DB round trips.
  • Exchange gateway: maintains the member's connections to the exchange, enforces the exchange's per-connection message rate limits by queuing and throttling, and handles reconnection and re-synchronisation (asking the exchange for order statuses after a disconnect).
  • GTT orders and alerts: stored on the broker side and evaluated against the live price stream; when a trigger hits, the broker places a regular order. This is a mini matching problem: index triggers by instrument and price, and scan only the price range crossed since the last tick.
  • Funds: pay-in via UPI/netbanking and pay-out to bank; a double-entry ledger per client as in a digital wallet.

Surviving 9:15 AM

  • Pre-scale and pre-warm everything before 9:00; logins and session creation spike first.
  • Accept AMO (after-market orders) the previous evening and queue them for submission at the open, smoothing the burst through the gateway's rate limits.
  • Isolate order placement from portfolio/report/charts traffic so a heavy positions page can't block orders.
  • Degrade non-critical features (charts history, analytics) first; never degrade order status accuracy.

Post-Trade: Clearing and Settlement

After matching, the clearing corporation steps in as the counterparty to both sides (novation), so neither trader carries the other's default risk. It nets each member's obligations, collects margins, and on settlement day shares move between demat accounts at the depositories (NSDL/CDSL) while funds move between banks. For Indian equities settlement is T+1, with an optional same-day settlement cycle introduced for a limited set of stocks. Brokers reconcile their internal trade book with exchange and clearing reports every day, the same reconciliation discipline described in payment system design.

Interview Angle

What interviewers are probing

  • Order book fluency: can you write the data structure and explain cancel in O(1)?
  • Why single-threaded? Determinism, no locks, replayability; scaling by partitioning instruments.
  • Durability without latency: journal before processing, replicate the input log, hot standby replay.
  • Fairness: sequencing defines who was first; the gateway must not reorder messages unfairly across members.
  • Broker realism: RMS before routing, idempotent application of exchange updates, the market-open spike, conflated market data.

Common mistakes

  • Putting a relational database or Kafka on the matching hot path.
  • Multi-threaded matching on the same book with locks.
  • Using a heap for the book and ignoring cancels.
  • Floating-point prices.
  • Pushing every tick to every client instead of conflating per subscription.
  • Mixing up broker and exchange responsibilities ("Zerodha matches my order with another Zerodha user").

Good follow-up answers to rehearse

  • "Primary engine crashes mid-burst." Standby has replayed the journal to sequence N; it takes over from N+1; members reconnect and gateways resend any unacknowledged messages, deduplicated by client order ID.
  • "How do you test a matching engine?" Deterministic replay of recorded sessions, invariant checks (book never crossed after matching, quantities conserved), and property-based tests on random order streams.
  • "How do you show a user's P&L live?" Positions × latest LTP from the in-memory price cache, computed client-side or on the push tier; no database round trip per tick.

The trading system is one of the few design problems where the right answer is to avoid distribution on the critical path. If you can explain that clearly, and still design a horizontally scaled broker around it, you'll stand out. For a contrast in ranking under heavy update load, see the real-time leaderboard, which faces a similar "one event changes many derived views" problem.

Frequently Asked Questions

What data structure is used for an order book?▾
Each side of the book is a sorted collection of price levels, bids sorted high to low and asks low to high, and each level holds a FIFO queue of orders to enforce time priority. A hash map from order ID to its position makes cancels constant time. High-performance engines often replace the sorted map with an array indexed by price tick for constant-time level access.
Why are matching engines single-threaded?▾
Matching must be deterministic and strictly ordered: the same input sequence must always produce the same trades. A single thread per instrument or partition processing a sequenced input stream avoids locks, keeps data hot in CPU caches and allows replicas to replay the same stream to reach identical state, which is how exchanges achieve both speed and fault tolerance.
What is the difference between a broker and a stock exchange?▾
An exchange like NSE or BSE runs the order book, matches buyers and sellers and publishes market data. A broker like Zerodha is a member of the exchange that provides the app, onboards and verifies customers, checks margins and risk, routes orders to the exchange, and shows positions and holdings. Clearing corporations and depositories handle settlement and custody of shares.
What is price-time priority?▾
It is the most common matching rule: the best price is matched first, and among orders at the same price, the earliest one is matched first. A trade executes at the price of the resting order that was already in the book, not the incoming aggressive order.
How do brokers stream live prices to millions of users?▾
The broker receives the exchange's market data feed, maintains the latest state per instrument, and pushes updates over WebSockets to clients subscribed to specific instruments. To control bandwidth it conflates updates, sending the latest snapshot at a fixed interval rather than every tick, and offers different modes such as last price only or full market depth.
Is stock exchange system design asked in Indian interviews?▾
Yes, especially at fintech and trading companies such as brokers, trading firms and payment companies, and as a harder variant at product companies. Interviewers usually focus on the order book, matching correctness, the sequencer and recovery, and how market data scales, rather than on UI features.

Prepare for Fintech System Design Interviews

Prepflix's system design course covers trading, payments and other low-latency fintech systems with mock interviews.

Explore the Prepflix Course →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.