Design a Stock Trading App

Design a retail brokerage app: live prices for millions of screens, orders that stay correct with an outside exchange, and the daily spike at the open.

Case study30 minLesson 4 of 10
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

You open a brokerage app on your phone. The price of the stock you are watching flickers every second. You tap Buy, 10 shares, limit $50.00, and a few seconds later the app says "Filled at $49.98". Behind that tap, your broker did not trade anything itself: it checked your cash, wrote the order down, and sent it to an exchange it does not control, over a network that can drop the answer.

Interviewers like it because it joins two problems that pull in opposite directions. Live prices are fan-out, where a second of staleness is fine. Orders are correctness with real money, where a lost or doubled message is a disaster. The question means the app (the kind of product a retail broker such as Robinhood runs), not the exchange. The steps follow A Framework for Any Design. Try each "Your turn" box before you read on.

Clarify the problem

The first five minutes
Interviewer

Design a stock trading app.

You

Are we the broker, sending orders to outside exchanges, or are we building the exchange that matches buyers and sellers?

Interviewer

The broker. Orders go to an external exchange.

You

Which features: live prices, placing and cancelling orders, order status? Which order types?

Interviewer

Those three. Market and limit orders, stocks only.

You

And the scale? How many people use the app at the busiest moment, and how many orders a day?

Interviewer

About 2 million people have the app open right after the market opens. We send about 5 million orders a day, and the first minute after the open is the busiest.

You

Then there are two paths with different rules. Prices can be a second stale but must reach 2 million screens. Orders must never be lost or doubled, even if a server crashes mid-call to the exchange. I'll design for the spike at the open and leave out options, crypto and charts.

  • Functional: stream live quotes for the symbols a user is looking at; place market and limit orders; cancel open orders; show each order's status as it changes (sent, filled, partly filled, cancelled, rejected).
  • Non-functional: quotes reach the screen within about 1 second of the exchange; an accepted order is never lost and never sent twice; status shown to the user always matches the exchange in the end; the order path stays up through the open, at about 20 times the average rate.
  • Out of scope: the matching engine, options and crypto, historical charts, account opening, tax reports.

Estimate the scale

Do prices and orders separately. These are the scenario's givens, not any real broker's figures.

  • Connections: 2 million at the open. At 50,000 per push server, 2,000,000 ÷ 50,000 = 40 servers. Run 80, half full, so they can absorb a dead server's clients.
  • Market data in: about 10,000 symbols, and up to 200,000 quote updates a second at the open. Nobody reads a price that changes 50 times a second, so send each symbol at most once a second: at most 10,000 updates a second, 20 times less.
  • Updates out: about 10 symbols per screen, so at worst 2,000,000 × 10 = 20 million symbol updates a second. One frame per client per second makes that 2 million frames, 25,000 per server.
  • Orders, average: a trading day is 6.5 hours = 23,400 seconds. 5,000,000 ÷ 23,400 ≈ 214 orders a second.
  • Orders, peak: assume the first minute carries 5% of the day: 250,000 ÷ 60 ≈ 4,200 orders a second, about 20 times the average.
  • Storage: about 2 KB per order with its history. 5 million × 2 KB = 10 GB a day; × 252 trading days ≈ 2.5 TB a year; about 18 TB if kept 7 years.
Estimate: Bandwidth for the price stream

At the open, how much outgoing bandwidth does the price stream need?

You know
  • 2 million connected clients
  • One frame per client per second
  • Each frame carries 10 symbols at about 40 bytes each

Show the working
  1. One frame: 10 × 40 B = 400 B
  2. 2,000,000 × 400 B = 800,000,000 B a second = 800 MB/s
  3. Spread over 80 push servers: 10 MB/s each

Estimate 800 MB/s (anything from 250 MB/s to 3 GB/s is a good answer)

800 MB/s is a lot for one place but easy for 80 servers. The cost is connections and frames, not bytes.

Estimate: Orders in the first minute

How many orders per second must the order path take in the first minute after the open?

You know
  • 5 million orders a day
  • The first minute after the open carries 5% of the day's orders

Show the working
  1. 5% of 5,000,000 = 250,000 orders
  2. 250,000 ÷ 60 s ≈ 4,200 a second
  3. The daily average is 5,000,000 ÷ 23,400 s ≈ 214 a second, so the open is about 20 times busier

Estimate 4.2 thousand/s (anything from 1.5 thousand/s to 12 thousand/s is a good answer)

A relational database partitioned by account handles 4,200 small transactions a second, but only if you scale up before 9:30, not after.

Your turn: Define the API

Write the calls for streaming quotes, placing an order and cancelling one. What does each return immediately, and what arrives later?

Compare with a model answer

Quotes and order updates share one long-lived connection. Placing an order returns "recorded", not "filled", and carries an id the app made, so a retry cannot create a second order. Cancel returns "requested": the order may fill first.

Define the API

Streaming and HTTP API
WebSocket /stream                         one per open app
  client → { "op": "subscribe",   "symbols": ["ACME", "ZETA"] }
  client → { "op": "unsubscribe", "symbols": ["ZETA"] }
  server → { "type": "quotes", "q": [ { "s": "ACME", "bid": 4997, "ask": 4999,
                                         "last": 4998, "ts": "09:30:01.200" } ] }
  server → { "type": "order", "id": "o-551", "status": "filled",
             "filled_qty": 10, "avg_price": 4998 }
 
POST /orders
  body: { "client_order_id": "c-7f3a", "symbol": "ACME", "side": "buy",
          "type": "limit", "qty": 10, "limit_price": 5000 }
  201 { "order_id": "o-551", "status": "pending" }
  200 the same body again   a retry with the same client_order_id
  422 { "reason": "insufficient buying power" | "market closed" | ... }
 
DELETE /orders/{id}
  202 { "status": "cancel_requested" }
  409 the order is already filled, cancelled or rejected
 
GET /orders/{id}          GET /orders?status=open
GET /quotes?symbols=ACME,ZETA     a snapshot for the first paint

Prices are integer cents: floating-point dollars round badly.

The decision worth arguing is what POST and DELETE promise. The outcome is decided later, at the exchange. So POST returns 201 once the order is durably recorded, DELETE returns 202, and the real result arrives as an event. An app that shows "Cancelled" on the 202 will one day show a cancelled order that actually filled.

The app creates client_order_id before the first attempt and reuses it on every retry. It is the order's idempotency key: a phone that loses the reply and resends gets the same order back.

Your turn: Model the data

Sketch the records for orders and fills. How do you make sure a retried order and a repeated fill message are each counted once?

Compare with a model answer

A unique key on (account, client order id) catches retried orders; a fills table keyed by the exchange's execution id catches repeated fills. Use a database with transactions: the order and its cash reservation commit together.

Model the data

Orders, fills and buying power
orders
  order_id         BIGINT PRIMARY KEY
  account_id       BIGINT
  client_order_id  VARCHAR(40)
  symbol           VARCHAR(8)
  side             VARCHAR(4)       -- buy | sell
  type             VARCHAR(6)       -- market | limit
  qty              INT
  limit_cents      BIGINT NULL
  status           VARCHAR(16)      -- pending | sent | accepted | partly_filled
                                    -- | filled | cancel_pending | cancelled | rejected
  filled_qty       INT
  reserved_cents   BIGINT           -- cash held back for this order
  updated_at       TIMESTAMP
  UNIQUE (account_id, client_order_id)
 
fills
  exec_id          VARCHAR(40) PRIMARY KEY   -- from the exchange
  order_id         BIGINT
  qty              INT
  price_cents      BIGINT
  executed_at      TIMESTAMP
 
accounts
  account_id       BIGINT PRIMARY KEY
  cash_cents       BIGINT
  reserved_cents   BIGINT           -- spendable = cash - reserved
 
positions
  account_id       BIGINT
  symbol           VARCHAR(8)
  qty              INT
  reserved_qty     INT              -- shares held back for open sells
  PRIMARY KEY (account_id, symbol)

Access patterns: insert an order and reserve cash (or, for a sell, shares) in one step; update it as reports arrive; list an account's open orders; apply each fill once. Each is a transaction within one account, so use a relational database partitioned by account_id; the open's 4,200 orders a second spread across partitions. Quotes are not stored at all: the latest per symbol lives in the feed handler's memory.

The database's unique keys do the deduplication, so no code can race: a retried order hits UNIQUE (account_id, client_order_id), and a repeated fill hits the exec_id key.

Your turn: High-level design

Sketch two paths: how a price travels from the exchange to 2 million screens, and how an order travels from a tap to the exchange and its status back.

Compare with a model answer

Prices: a feed handler keeps the latest quote per symbol and publishes it once a second to a topic per symbol; push servers subscribe for their clients. Orders: an order service records each order before calling the exchange and publishes status events back through the same pub/sub.

High-level design

The top half is the price path, the bottom half the order path. Both reach the app through the push servers.

Prices flow up and left from the exchange through a conflating feed handler and pub/sub. Orders flow down and right, and are written to the database before they reach the exchange.Exchange to Feed handler (all ticks); Feed handler to Pub/sub (1/s per symbol); Pub/sub to Push servers (topics); Push servers to Trading app (WebSocket); Trading app to API gateway (orders); API gateway to Order service; Order service to Orders DB (write first); Order service and Exchange (send / fills); Order service to Pub/sub (order events), asynchronous.Trading appPush serversAPI gatewayPub/subOrder serviceFeed handlerExchangeOrders DBall ticks1/s per symboltopicsWebSocketorderswrite firstsend / fillsorder events

request and replyasynchronous message

Prices flow up and left from the exchange through a conflating feed handler and pub/sub. Orders flow down and right, and are written to the database before they reach the exchange.

Step through a quote, an order sent during a crash, and its fill.

A quote, an order, a crash mid-send, and the fillUse the arrows or ← → keys
  1. A quote, an order, a crash mid-send, and the fill, step 1Exchange to Feed handler; Feed handler to Pub/sub; Pub/sub to Push servers; Push servers to Trading app; Trading app to API gateway; API gateway to Order service; Order service to Orders DB; Order service and Exchange; Order service to Pub/sub, asynchronous.Trading appPush serversAPI gatewayPub/subOrder serviceFeed handlerExchangeOrders DB
    ACME last
    $49.97

    1Ana opens ACME. Her push server subscribes to the ACME topic, unless it already has, and sends a snapshot.

  2. A quote, an order, a crash mid-send, and the fill, step 2Exchange to Feed handler; Feed handler to Pub/sub; Pub/sub to Push servers; Push servers to Trading app; Trading app to API gateway; API gateway to Order service; Order service to Orders DB; Order service and Exchange; Order service to Pub/sub, asynchronous.Trading appPush serversAPI gatewayPub/subOrder serviceFeed handler40 in, keep lastExchangeOrders DB
    ACME last
    $49.97
    ticks this second
    40

    2In one second the exchange sends 40 ACME updates. The feed handler just overwrites its latest ACME quote.

  3. A quote, an order, a crash mid-send, and the fill, step 3Exchange to Feed handler; Feed handler to Pub/sub; Pub/sub to Push servers; Push servers to Trading app; Trading app to API gateway; API gateway to Order service; Order service to Orders DB; Order service and Exchange; Order service to Pub/sub, asynchronous.Trading appPush serversAPI gatewayPub/subOrder serviceFeed handlerExchangeOrders DB
    ACME last
    $49.98
    messages sent
    1, not 40

    3Once a second it publishes the full quote. Each push server gets one copy and puts it in the next frame of every client watching ACME.

  4. A quote, an order, a crash mid-send, and the fill, step 4Exchange to Feed handler; Feed handler to Pub/sub; Pub/sub to Push servers; Push servers to Trading app; Trading app to API gateway; API gateway to Order service; Order service to Orders DB; Order service and Exchange; Order service to Pub/sub, asynchronous.Trading appPush serversAPI gatewayPub/subOrder serviceFeed handlerExchangeOrders DB
    client_order_id
    c-7f3a
    order
    none yet

    4Ana taps Buy, 10 at a $50.00 limit. The app creates client_order_id c-7f3a before sending, so retries carry the same id.

  5. A quote, an order, a crash mid-send, and the fill, step 5Exchange to Feed handler; Feed handler to Pub/sub; Pub/sub to Push servers; Push servers to Trading app; Trading app to API gateway; API gateway to Order service; Order service to Orders DB; Order service and Exchange; Order service to Pub/sub, asynchronous.Trading appPush serversAPI gatewayPub/subOrder serviceFeed handlerExchangeOrders DBo-551 pending
    client_order_id
    c-7f3a
    order
    pending
    reserved
    $500.00

    5One transaction inserts o-551 as pending and reserves 10 × $50.00 = $500. It commits on two machines before anything is sent.

  6. A quote, an order, a crash mid-send, and the fill, step 6Exchange to Feed handler; Feed handler to Pub/sub; Pub/sub to Push servers; Push servers to Trading app; Trading app to API gateway; API gateway to Order service; Order service to Orders DB; Order service and Exchange; Order service to Pub/sub, asynchronous.Trading appPush serversAPI gatewayPub/subOrder servicecrashedFeed handlerExchangehas c-7f3aOrders DB
    client_order_id
    c-7f3a
    order
    pending
    reserved
    $500.00

    6The order service sends it, tagged c-7f3a, and crashes before recording sent. The exchange did receive it.

  7. A quote, an order, a crash mid-send, and the fill, step 7Exchange to Feed handler; Feed handler to Pub/sub; Pub/sub to Push servers; Push servers to Trading app; Trading app to API gateway; API gateway to Order service; Order service to Orders DB; Order service and Exchange; Order service to Pub/sub, asynchronous.Trading appPush serversAPI gatewayPub/subOrder serviceFeed handlerExchangeOrders DBc-7f3a exists
    client_order_id
    c-7f3a
    order
    pending
    orders created
    1

    7Ana's app times out and retries with c-7f3a. Another order server finds the row by the unique key and returns o-551. No second order.

  8. A quote, an order, a crash mid-send, and the fill, step 8Exchange to Feed handler; Feed handler to Pub/sub; Pub/sub to Push servers; Push servers to Trading app; Trading app to API gateway; API gateway to Order service; Order service to Orders DB; Order service and Exchange; Order service to Pub/sub, asynchronous.Trading appPush serversAPI gatewayPub/subOrder serviceFeed handlerExchangec-7f3a acceptedOrders DB
    client_order_id
    c-7f3a
    order
    accepted
    orders at exchange
    1

    8The reconciler finds o-551 pending for over 5 seconds. Instead of resending, it asks the exchange about c-7f3a: accepted. So is the row.

  9. A quote, an order, a crash mid-send, and the fill, step 9Exchange to Feed handler; Feed handler to Pub/sub; Pub/sub to Push servers; Push servers to Trading app; Trading app to API gateway; API gateway to Order service; Order service to Orders DB; Order service and Exchange; Order service to Pub/sub, asynchronous.Trading appPush serversAPI gatewayPub/subOrder serviceFeed handlerExchangeOrders DBfill E-90
    order
    filled
    cost
    $499.80
    released
    $0.20

    9A fill arrives: 10 at $49.98, execution id E-90. One transaction inserts the fill, marks the order filled and releases the unused $0.20.

  10. A quote, an order, a crash mid-send, and the fill, step 10Exchange to Feed handler; Feed handler to Pub/sub; Pub/sub to Push servers; Push servers to Trading app; Trading app to API gateway; API gateway to Order service; Order service to Orders DB; Order service and Exchange; Order service to Pub/sub, asynchronous.Trading appPush serversAPI gatewayPub/subOrder serviceFeed handlerExchangeOrders DBE-90 exists
    order
    filled
    shares
    10

    10After a reconnect the exchange repeats the fill. Inserting E-90 again hits the primary key: Ana does not get 20 shares.

  11. A quote, an order, a crash mid-send, and the fill, step 11Exchange to Feed handler; Feed handler to Pub/sub; Pub/sub to Push servers; Push servers to Trading app; Trading app to API gateway; API gateway to Order service; Order service to Orders DB; Order service and Exchange; Order service to Pub/sub, asynchronous.Trading appPush serversAPI gatewayPub/subOrder serviceFeed handlerExchangeOrders DB
    order
    filled
    shares
    10

    11The order service publishes to Ana's account topic; her push server forwards it and the app shows "Filled at $49.98".

1 / 11

Deep dives

Fanning out live prices

The naive design sends all 200,000 ticks a second to every watcher of each symbol. Three ideas shrink that.

  • Conflate at the source. Keep the latest quote per symbol and publish once a second: at most 10,000 messages instead of 200,000. Each is the full quote, never a change, so a lost message is repaired by the next one.
  • Subscribe per server, not per client. A push server subscribes to ACME once. A symbol on 1 million screens costs the pub/sub 80 messages a second, one per server; each server copies it to its own 12,500 watchers.
  • Batch per client. One frame a second holds all of a client's symbols.
How the app receives prices
OptionGood atBad atPick it when
PollingPlain HTTP, cacheable, nothing stateful on the server.Polling every second is 2 million requests a second, each with full headers.Updates every 30 seconds or more are good enough.
Server-sent eventsServer-to-client stream over ordinary HTTP. Browsers reconnect on their own.One direction, so subscription changes need separate calls routed to the server holding the stream.The client rarely changes what it listens to.
WebSocket Usual defaultBoth directions on one connection: subscriptions change as the user scrolls, and order updates share it.Stateful servers, connection draining on deploys, and your own reconnect and resubscribe logic.Subscriptions change constantly, as on a watchlist. The choice here.

On reconnect, the app resends its subscriptions and gets snapshots; quotes are full state, so nothing needs replaying. Order events are never conflated, and the app also calls GET /orders?status=open after a reconnect, so a status change sent while it was away is not lost. Real-time communication covers holding connections at this scale.

Keeping orders right with an outside exchange

Your database and the exchange cannot share a transaction, so there is always a moment when only one of them knows about the order. You choose which one.

Ordering the database write and the exchange call
OptionGood atBad atPick it when
Call the exchange, then writeNo record of orders that never left.Crash after the call and a live order exists with no row and no reserved cash.Never, for money.
Write first, then call Usual defaultEvery order the exchange can know has a row first. A crash leaves a pending row you resolve by asking.Some pending rows were never sent; you need a reconciler to find and settle them.The usual answer when the same service writes and sends.
Write with an outboxThe order and an outbox row commit together; a separate sender reads the outbox and calls the exchange.One more moving part; the sender still needs the status check before any resend.Sending is a separate service, or you want sending to survive the order service being down.

The rule: the row with the client order id is durable before the exchange can hear about the order. Durable means committed on a synchronous replica too. Then three rules keep every case safe.

  • Never resend blindly. A pending order may or may not have arrived. Order protocols such as FIX carry your client order id on every message, so ask the exchange about that id; send only if it has never heard of it. Ask only after the crashed server's exchange session is closed, so its message cannot still be on the way. A network cannot give exactly-once delivery; sending at least once plus this check gives the effect of once.
  • Apply reports idempotently, forward only. Fills are keyed by execution id; filled_qty is the sum of distinct fills. A late "accepted" after "filled" changes nothing.
  • Reconcile twice. Continuously, for orders stuck in pending or cancel_pending. And daily, against your clearing firm's trade report; any difference goes to a human.

Cancels race with fills. Ana taps Cancel; the exchange fills her order 10 ms later, then gets the cancel and answers "too late to cancel". So the order sits in cancel_pending, and the app says "Cancelling…", until the exchange decides.

Surviving the open

This spike is known in advance: 9:30 every trading day. Reactive autoscaling reacts minutes after load arrives, which is too late. So:

  • Scale on a schedule. Push servers, order servers and database capacity reach the open's level by 9:15.
  • Spread the reconnect storm. Clients add random jitter to reconnects; snapshots come from memory, never a database.
  • Drain the overnight queue at a set rate. Orders placed while the market was closed go out at the open, and exchange connections have agreed message limits. Assume 10 connections at 1,000 messages a second and 100,000 queued orders. Live orders use 4,200 of the 10,000, so the queue drains in 100,000 ÷ 5,800 ≈ 17 seconds. Tell users "at or shortly after the open", or add connections.
  • Shed the right load. Charts and news give way first, never orders; cancels get top priority because they reduce risk.

Bottlenecks and failures

At 10x, the price path grows linearly: 800 push servers, 8 GB/s. The pub/sub barely grows, because it fans out per server. Orders at 42,000 a second need more account_id partitions and more exchange connections, which take time to arrange, so plan them early.

When this diesWhat happensWhat you do
A push serverIts 25,000 clients lose their stream.They reconnect with jitter elsewhere, resubscribe and fetch open orders. Half-full servers have room.
The feed handlerPrices freeze on every screen.Run a standby handler. Quotes carry timestamps; the app greys out any older than 5 seconds.
The pub/subQuotes and order events stop reaching push servers, though the apps stay connected.Run it replicated. When a push server's link to it recovers, it tells its apps to refetch open orders from the database.
An order server mid-sendSome orders are pending with an unknown fate.The reconciler asks the exchange about each one, then sends or settles it.
The orders DB primaryNo new orders can be recorded.Fail over to the synchronous replica; return 503 meanwhile, never send unrecorded orders.
The exchange connectionOrders in flight have unknown results.Stop sending, reconnect, ask for the status of every open order, then resume.

What a strong answer sounds like

LevelWhat the answer does
JuniorDraws an app, a server and a database, polls for prices, and calls the exchange straight from the order endpoint.
MidUses a pub/sub with a topic per symbol and WebSockets, makes order placement idempotent with a client order id, and keeps money in integer cents.
SeniorSeparates the approximate price path from the exact order path, conflates ticks, writes before calling the exchange and resolves the crash in between by asking, treats cancel as a request, and scales for 9:30 before 9:30.
The crash in the middleWeak answer
Interviewer

Your order server crashes right after sending an order to the exchange. What happens?

You

The app retries, so the order goes through on another server.

The crash in the middleStrong answer
Interviewer

Your order server crashes right after sending an order to the exchange. What happens?

You

I committed the order as pending with the app's client order id before sending, so the row survives, and the app's retry with that id gets it back. A reconciler sees it stuck, asks the exchange about that id and records the answer. It sends only if the exchange has never heard of it.

Interviewer

Why not send first and write after? It's faster.

You

Then the same crash leaves a live order with no row and no reserved cash. A pending row I can resolve; an unknown order I can't. It costs a few milliseconds.

Follow-up questions

How would you add stop orders?

A trigger service sends a market order when the stop price is crossed. It reads the full feed, not the conflated stream, or it can miss a brief cross. A conditional update on the stop's status makes it fire once.

How do you show the portfolio's value live?

The app already gets quotes for its holdings, so it multiplies shares by price itself. The server sends positions on connect and after fills, instead of revaluing 2 million portfolios a second.

How do you stop a user spending the same cash twice?

The reservation is a conditional update in the same transaction as the order insert: SET reserved_cents = reserved_cents + 50000 WHERE account_id = :a AND cash_cents - reserved_cents >= 50000. Two orders racing for the last $500 cannot both pass, because the account row is updated one transaction at a time.

An order fills 4 of 10 shares, then the user cancels. What happens?

The exchange cancels the other 6 and the order ends cancelled with filled_qty 4. The cash reserved for the 6 is released. The cancel report and a late fill can arrive in either order, so apply each by its own key and never let a report lower filled_qty.

How would you add a second exchange?

The order service picks the venue and writes it on the row before sending, with one connection pool per venue. The reconciler then knows which exchange to ask about a pending order.

Self-review: stock trading app0 of 14

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure