← System design

Financial & Trading

Design Stock Trading Platform

A buy button on a phone hides two very different systems. One moves money and shares, where a duplicated order or a lost fill is a regulatory incident. The other streams prices to a million and a half screens at 9:30 in the morning, where a second of delay is fine but falling over is not. The design keeps them separate and gives each the guarantees it actually needs.
Core: event-sourced orders + derived buying power1.5M sessions at the market openExactly-once money effectsT+1 settlement and nightly reconciliation

01

The problem

01

The problem

A retail brokerage app lets ordinary people buy and sell stocks from their phone. The user sees a price ticking, taps buy, and a moment later owns shares. Behind that tap the broker has to check that the user can afford the order, route it to a market maker or exchange, track partial fills that arrive over the next few hundred milliseconds, update the account, and later settle the trade with a clearing house one business day after it happened.

Two very different workloads share the app. The money path handles orders, cash and positions, where a single lost fill or double-counted deposit is a regulatory problem. The market data path streams prices to a million or more open screens, where being a second late is fine but falling over at the market open is not. A good design treats them as separate systems with different consistency rules.

Clarify the product

Ask whether the broker is self-clearing or uses a clearing firm, whether it offers margin, options, fractional shares or crypto, and which markets it serves. This page assumes US equities with fractional shares and a cash plus margin account, and treats options as a follow-up.

02

What the platform must do

02

What the platform must do

Functional
  • Place market, limit, stop and stop-limit orders for whole or fractional shares, and cancel or replace open orders.
  • Show live quotes, a price chart and the order book summary for any symbol, plus a streaming watchlist.
  • Show positions, cost basis, today's gain or loss, and buying power that is correct at every moment.
  • Deposit and withdraw cash through bank transfers, with an option to trade before a deposit has fully cleared.
  • Notify users when orders fill, when price alerts trigger, and when corporate actions such as splits or dividends affect their holdings.
  • Produce statements, tax documents and the records regulators require.
Non-Functional
  • Order placement acknowledged in under 200 ms at p99 inside the broker, plus however long the venue takes.
  • Exactly-once effects on money: no duplicate orders, no lost fills, no cash counted twice.
  • Market data visible to users within about a second of the exchange publishing it.
  • 99.99% availability for order placement during market hours; planned maintenance only outside them.
  • Every state change auditable and reproducible for at least six years.

03

Sizing the load

03

Sizing the load

Assume 18 million funded accounts, 4 million users opening the app on a typical trading day, and up to 1.5 million connected at the market open. Orders average 6 million a day, but they arrive very unevenly.

QuantityWorkingResult
Orders per dayassumption6M
Average order rate6M / 23,400 trading secondsabout 260 / s
Open, first five minutes8% of daily orders in 300 sabout 1,600 / s
Meme-stock day10x the normal openabout 16,000 / s
Events per ordercreated, accepted, routed, partial fills, doneabout 6
Order event data per day6M x 6 x 300 Babout 11 GB
Concurrent app sessions at the openassumption1.5M
Symbols on screen per sessionwatchlist tiles plus one detail chartabout 8
Outbound frames at the open1.5M sessions x 1 batched frame per secondabout 1.5M frames / s
Inbound market dataconsolidated feed for about 12,000 US equities at peakabout 2M messages / s
WebSocket servers1.5M / 60K connections each, plus headroomabout 35
Positions held18M accounts x 9 positions on averageabout 160M rows
Two shapes of load

The money path is small in volume but spiky: 16,000 orders a second on the worst day is easy for a database, if it does not all hit one row. Market data is the opposite, millions of messages a second that are individually unimportant. Sizing them separately is the main point of this table.

The trick that makes streaming affordable

Never send one message per price change per user. Batch every symbol update for a session into one frame per second, keeping only the latest price per symbol. Outbound traffic then scales with the number of sessions, not with how busy the market is.

04

API design

04

API design

Trading

POST/v1/ordersPlace an order
// Authorization: Bearer <session token> // Idempotency-Key: 5c0b7e12-4b9a, the same key always returns the same order { "account_id": "acc_30418", "symbol": "ACME", "side": "buy", "type": "limit", "limit_price": "182.40", // decimal string, never a float "notional": null, // set this instead of quantity for dollar-based fractional orders "quantity": "2.5", "time_in_force": "day" } // 202 Accepted { "order_id": "ord_01J9ZQ8M5K", "state": "accepted", "buying_power_held": "456.00" }
DELETE/v1/orders/{order_id}Request a cancel
// 202: cancel requested. The order may still fill before the venue confirms. { "order_id": "ord_01J9ZQ8M5K", "state": "pending_cancel" }
GET/v1/orders/{order_id}/eventsFull history of one order
{ "events": [ { "seq": 1, "type": "created", "at": "2026-10-01T13:30:00.412Z" }, { "seq": 2, "type": "routed", "venue": "MM_WHOLESALE_2", "at": "2026-10-01T13:30:00.431Z" }, { "seq": 3, "type": "partial_fill", "qty": "1.0", "price": "182.38", "exec_id": "X-77120" }, { "seq": 4, "type": "filled", "qty": "1.5", "price": "182.39", "exec_id": "X-77134" } ] }

Streaming prices

// client -> server over WebSocket { "op": "subscribe", "symbols": ["ACME", "GLOBX", "NIMB"], "channel": "quotes" } // server -> client, at most one frame per second, latest value per symbol { "op": "quotes", "t": 1759325400, "q": { "ACME": { "bid": "182.38", "ask": "182.41", "last": "182.39" }, "NIMB": { "bid": "9.12", "ask": "9.13", "last": "9.13" } } } // order updates for this user arrive on the same socket { "op": "order", "order_id": "ord_01J9ZQ8M5K", "state": "filled", "avg_price": "182.386" }

Portfolio

GET/v1/accounts/{account_id}/portfolioPositions and buying power
{ "buying_power": "1203.55", "cash": { "settled": "980.10", "unsettled": "420.00", "held_for_orders": "456.00" }, "positions": [ { "symbol": "ACME", "quantity": "12.5", "avg_cost": "171.20", "market_value": "2279.88" } ], "as_of_sequence": 88210443 // ledger position this view reflects }

Every money field is a decimal string. Floating point cannot represent 0.1 exactly, and a broker that loses fractions of a cent per trade across millions of trades will have an audit problem.

05

Architecture overview

05

Architecture overview

Money path and market data pathDiagram
flowchart LR
	APP["Mobile and web apps"] --> GW["API gateway"]
	GW --> OS["Order service"]
	OS --> LG[("Ledger and buying power")]
	OS --> OB[("Order events, Postgres")]
	OB -. "outbox" .-> K[["Kafka"]]
	K --> RT["Order router"]
	RT --> V["Market makers and exchanges, FIX"]
	V --> FP["Execution handler"]
	FP --> OB
	FP --> LG
	FH["Feed handlers"] --> QB["Quote bus"]
	QB --> WS["WebSocket edge"]
	WS --> APP
	K --> WS

On the money path, the order service validates a request, checks and holds buying power in the ledger, and writes an order created event, all in one database transaction. A transactional outbox then publishes the event to Kafka, where the order router picks it up, decides which venue to send it to and sends it over a FIX session. Execution reports come back to the execution handler, which records fills as new order events and moves cash and shares in the ledger, again in one transaction per fill.

On the market data path, feed handlers read the consolidated and direct exchange feeds, normalise them, and publish the latest quote per symbol onto an in-memory quote bus. WebSocket edge servers subscribe to the symbols their connected users care about and, once a second, send each user one frame with the latest values. The same sockets carry order updates, which the edge receives from Kafka filtered to the users connected to it.

A third, slower path runs after the close: reconciliation and settlement. Trades are reported to the clearing firm, settlement happens one business day later, and every night the broker compares its own records of cash, positions and trades against the clearing firm's files and the bank's statements.

Rough sizing

Order and ledger database

Postgres, partitioned by account ID across a handful of primaries with synchronous replicas. Order volume is tiny for a database; the requirement is strict transactions, not throughput.

Order router

A few instances, each owning a set of FIX sessions with venues. FIX sessions are stateful with sequence numbers, so each one has exactly one owner at a time.

Quote bus

An in-memory pub/sub layer keyed by symbol, replicated in each region. It holds only the latest value per symbol and has no durability requirement.

WebSocket edge

About 35 servers at the open, each holding up to 60,000 connections. Users stay pinned to a server, and any server can be lost without affecting orders.

06

Deep dives: orders, buying power, routing and the market open

06

Deep dives: orders, buying power, routing and the market open

What gets probed

Expect questions on how an order avoids being placed twice, how buying power stays right while fills trickle in, where orders get routed and why, and how you survive the first minute after 9:30. The settlement and reconciliation story separates strong answers from average ones.

1. The order as a sequence of events

Order statesDiagram
flowchart LR
	N["Created"] --> A["Accepted, buying power held"]
	A --> R["Routed to venue"]
	R --> P["Partially filled"]
	P --> P
	P --> F["Filled"]
	R --> F
	R --> PC["Pending cancel"]
	P --> PC
	PC --> C["Cancelled"]
	PC --> F
	R --> RJ["Rejected by venue"]

Store each order as an append-only list of events, not as a row whose status column is overwritten. The current state is a fold over the events, and a materialised orders_current table is updated in the same transaction for fast reads. This gives an exact audit trail for free, makes it obvious when a fill arrived relative to a cancel request, and lets you rebuild any account's history by replaying.

Notice the arrow from pending cancel to filled. A cancel is only a request until the venue confirms it. The order can fill in the meantime, and the system must accept that fill and update the account. Treating cancel as instant is a common bug that leaves a user owning shares the app says they do not have.

2. Exactly once across three boundaries

BoundaryRiskDefence
App to brokerA retry after a timeout places the order twiceIdempotency key stored with the order in the same transaction; a repeat returns the original
Broker to venueThe router crashes after sending, then resends on restartUnique ClOrdID per order, and FIX session sequence numbers with resend requests instead of blind resubmission
Venue to brokerThe same execution report is delivered twice after a reconnectUnique constraint on the venue's execution ID; the second insert is ignored

No network protocol gives true exactly-once delivery. What you build is at-least-once delivery plus idempotent handling at every hop, which produces exactly-once effects on the account. The outbox pattern closes the last gap: the order event and the message to Kafka are written in the same database transaction, so the router can never miss an order the user was told was accepted.

3. Buying power is derived, not stored

Buying power is the amount a user can spend right now, and it moves for many reasons: fills, cancels, deposits clearing, sells settling, margin changes. Storing it as a single number and adjusting it from many places invites drift. Instead, compute it from the ledger.

buying_power = settled_cash + unsettled_sale_proceeds # allowed in margin accounts; in cash accounts with care + instant_deposit_credit # capped, see below - holds_for_open_buy_orders - pending_withdrawals + margin_available # margin accounts only

Placing a buy order writes a hold to the ledger for its maximum cost: limit price times quantity, or a collared price for market orders. Each fill converts part of the hold into a debit at the actual price and releases the difference. A cancel releases what is left. All of this happens in transactions on the user's account partition, with a row lock on the account balance row, so two orders placed at the same instant cannot both spend the same dollars.

Market buy orders have no price, so the hold needs a guess. A common choice is to convert them into limit orders with a collar a few percent above the current ask. That protects the user from a fill at a wildly bad price in a fast market and gives the ledger a firm maximum to hold.

4. Deposits that have not arrived yet

A bank transfer through ACH takes one to three business days to settle and can be reversed days later if the user's bank rejects it. Users hate waiting, so brokers extend instant credit for part of the deposit, for example the first 1,000 dollars. That credit is a loan. If the transfer bounces after the user bought and lost money on a volatile stock, the broker carries the loss.

So instant credit is limited by account age, history and risk score, and the positions bought with it cannot be withdrawn as cash until the deposit settles. When a reversal does arrive, the ledger records a negative cash entry, the account is restricted to closing trades, and collections begin. Model this explicitly in the interview: it is the most common way a retail broker loses money through its own system.

5. Where the order goes

In the US, a broker must seek best execution under Regulation NMS, which in practice means not trading through a better displayed price on another exchange. The order router keeps the current best bid and offer across venues and chooses where to send each order. Many retail brokers send most orders to wholesale market makers, which often fill at or slightly better than the best displayed price and pay the broker for the flow. That arrangement, payment for order flow, is legal in the US and controversial; mention it neutrally if it comes up.

The router also handles fractional shares. Venues trade whole shares, so the broker fills fractional orders from its own inventory and buys or sells whole shares to rebalance that inventory. That turns the broker into a principal for the fractional part, with its own position limits and risk checks.

6. Fan-out at the open

At 9:30 Eastern, a large share of users open the app at once. Three things keep the streaming tier upright. First, conflation: the edge server keeps only the latest quote per symbol and sends one frame per user per second, so a symbol that ticks 400 times in a second costs the same as one that ticks once. Second, shared subscriptions: each edge server subscribes once per symbol to the quote bus no matter how many of its users watch it. Third, reconnect pacing: clients reconnect with jittered backoff, and the edge accepts connections at a controlled rate so a server restart does not become a stampede.

Order updates use the same sockets but a different source. The edge consumes order events from Kafka, keyed by account, and forwards only those for its connected users. If the user is not connected, a push notification goes out instead.

7. After the close: settlement and reconciliation

US equities settle on T+1: a trade on Monday finalises on Tuesday, when cash and shares actually change hands through the clearing house. Until then, sale proceeds are unsettled. A cash account that buys with unsettled proceeds and sells again before settlement can commit a good faith violation, which the platform must detect and warn about.

Every night, a reconciliation job compares three views: our ledger, the clearing firm's position and cash files, and the bank's records of transfers. Any difference becomes a break that an operations team resolves the next morning, and the job refuses to mark the day closed until breaks are explained. Corporate actions run through the same machinery: a 4-for-1 split multiplies quantities and divides cost basis, a dividend becomes a cash credit on the pay date, and both are applied as ledger events so history stays reproducible.

8. When the market itself breaks

Markets halt. A single stock can be paused under limit up, limit down bands, and the whole US market pauses if the S&P 500 falls 7%, 13% or 20% in a day. The router must stop sending orders to halted symbols and queue or reject them clearly. A broker also needs its own brakes: in January 2021 clearing house deposit requirements jumped overnight during the meme-stock surge, and several brokers had to restrict buying in a handful of stocks. Build a per-symbol switch that limits trading to closing positions, and design it before you need it.

Short version

Event-sourced orders in Postgres with an outbox to Kafka, idempotency at every boundary, buying power derived from a ledger with holds, a router that respects best execution and handles fractional inventory, a conflating WebSocket tier for prices, and nightly reconciliation against the clearing firm and the bank.

07

Decisions and what they cost

07

Decisions and what they cost

Chosen
Relational database for orders and the ledger

Transactions across a hold, an order event and an outbox row are the core of correctness here, and the volume is small. Partition by account to scale.

Correctness first
Rejected
Eventually consistent NoSQL for orders

Scales writes easily, but multi-row atomic updates and strict uniqueness are harder, and order volume never needed that scale.

Solves the wrong problem
Chosen
Event-sourced orders with a current-state table

Full history for audit and debugging, and a clear record of fills that race cancels. The current-state table keeps reads cheap.

Audit for free
Alternative
Mutable order rows with an audit log

Simpler queries, but the audit log becomes a second source of truth that can disagree with the row.

Two truths
Chosen
Transactional outbox

The event and its publication commit together. A relay reads the outbox table and publishes to Kafka, retrying until acknowledged. Easy to reason about.

No lost orders
Alternative
Change data capture from the write-ahead log

No extra table, and catches every change. Ties consumers to the database schema and adds an operational component such as Debezium.

Good at larger scale
Chosen
WebSocket with one conflated frame per second

Bidirectional, one connection per app for prices and order updates, and predictable outbound volume.

Fits a trading app
Alternative
Server-sent events plus HTTP for subscriptions

Simpler through proxies and automatically reconnects. Changing subscriptions needs a separate call, and binary framing is awkward.

Fine for web-only

08

Failure modes

08

Failure modes

🔁
A user's order is sent to the venue twice

The router sends an order, crashes before recording that it did, and on restart sees the order as unsent. Without care it sends a second copy with a new ID and the user buys twice.

Each order has a fixed ClOrdID chosen before the first send. On restart the router asks the venue for order status over FIX, or relies on the venue rejecting the duplicate ID, instead of minting a new one.
🏦
A wave of deposit reversals

A bank partner's outage causes a batch of ACH transfers to be returned three days later. Some of those users already traded on instant credit, and a few lost money.

Cap instant credit by risk score, block withdrawals of positions bought on credit until settlement, and restrict affected accounts to closing trades the moment a return arrives. Track total outstanding instant credit as a company-level risk limit.
📈
A meme stock takes over the open

One symbol gets a third of all orders. Every buy needs a lock on the user's account row, which is fine, but the router's FIX sessions to one market maker saturate and acknowledgements slow down.

Spread flow across several venues and sessions, apply per-symbol rate limits at the router, and enable the closing-only switch if the clearing firm raises deposit requirements beyond what the broker can post.
🔌
A WebSocket server restarts at 9:30

Sixty thousand clients disconnect at once and reconnect immediately, all landing on the remaining servers while the market is busiest.

Jittered client backoff, connection admission rate limits on each edge server, and enough spare capacity to absorb two failed servers. Orders are unaffected because they travel over HTTP.
🧾
Nightly reconciliation finds a break

Our ledger shows a user holding 10 shares; the clearing firm shows 9. A partial fill report was dropped and never recorded.

Do not auto-correct. Open a break, freeze the affected position for sells beyond the agreed quantity, request the venue's execution records, and replay the missing fill through the normal execution handler so history stays consistent.
⛔
A trading halt is ignored

An exchange halts a symbol, but the router does not process the halt message and keeps sending orders, which venues reject. Users see confusing errors at a tense moment.

Subscribe to halt and resume messages from every venue, mark the symbol halted in the router and the app, and queue or reject new orders with a clear message until trading resumes.

09

How to handle the interview

09

How to handle the interview

1

Split money from market data on the whiteboard. Different volumes, different consistency rules, different failure stories. Saying this first frames everything else.

2

Use events for orders. Event-sourced orders with fills and cancels as events show you understand that a cancel can lose a race with a fill.

3

Name the three idempotency boundaries. App to broker, broker to venue, venue to broker. Give the defence for each.

4

Derive buying power from a ledger. Holds for open orders, settled versus unsettled cash, instant credit. Explain the account-level lock that stops double spending.

5

Bring up ACH reversals. It is a real source of loss for retail brokers and most candidates never mention it.

6

Explain conflation for streaming. One frame per user per second with the latest quote per symbol. Show the arithmetic that makes the tier affordable.

7

Know a little regulation. Best execution, T+1 settlement, halts and market-wide circuit breakers. A sentence each is enough to show awareness.

8

End with reconciliation. Nightly comparison against the clearing firm and the bank is how a broker proves its books are right.

10

Growing the design in stages

11

Growing the design in stages

Rule of thumb

The money path rarely needs more throughput; it needs more safety. Scale the streaming tier for traffic and the order path for correctness.

Stage 1: launch
One service on top of a broker API

Orders go to a broker-dealer partner's API, quotes come from a vendor, and a single Postgres holds users and orders. Fastest way to learn whether anyone wants the product.

Stage 2: your own broker-dealer
Order service, ledger and FIX router

You hold customer accounts, route orders over FIX to market makers, and keep an event-sourced order store with an outbox. Nightly reconciliation with the clearing firm starts on day one.

Stage 3: a million users
Dedicated streaming tier

Market data moves to feed handlers, a quote bus and conflating WebSocket servers. Push notifications for fills and alerts go through their own service.

Stage 4: busy opens and volatile days
Partitioned ledger, smarter routing, risk switches

The ledger is partitioned by account, the router spreads flow across venues, and per-symbol closing-only switches and instant credit limits exist before they are needed.

Stage 5: more products
Options, crypto and self-clearing

Options bring far more market data and new margin rules, and self-clearing removes a partner but makes the broker directly responsible for settlement. Each new asset class adds its own settlement and risk path.

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.