The order matching engine maintains two sides: bids (buy orders) sorted by price descending then time, and asks (sell orders) sorted by price ascending then time. When a new order arrives, it's matched against the opposite side's best price. If no match, it joins the book. The system must handle concurrent orders, partial fills, cancellations, and market data dissemination without data loss.
✦ Definition~90s read
What is Design a Stock Exchange?
A stock exchange is a system that matches buy and sell orders for financial instruments. The core is an order matching engine that maintains an order book, enforces price-time priority, and executes trades with strict consistency and low latency.
★
Think of a stock exchange like a busy auction house with two boards: one for people wanting to buy (bids) and one for people wanting to sell (asks).
Plain-English First
Think of a stock exchange like a busy auction house with two boards: one for people wanting to buy (bids) and one for people wanting to sell (asks). The auctioneer (matching engine) always calls out the highest bid and lowest ask. When a buyer's price meets a seller's price, a trade happens. The auctioneer must never miss a bid or ask, and must always match in the order they arrived. That's the exchange's job — a fair, fast, and reliable marketplace.
I've seen a matching engine lose $2 million in 47 milliseconds because of a race condition in the order book. The order was filled twice. The exchange had to reverse the trades, and the clearing house almost failed. That's the stakes when you design a stock exchange. This is not a toy project — it's the backbone of capital markets. The matching engine must be correct, fast, and resilient. In this article, you'll learn how to build an order matching engine from scratch: the data structures, the concurrency model, the failure modes, and the production hacks that separate a demo from a real exchange. By the end, you'll be able to design a system that can handle thousands of orders per second without losing a single trade.
The Order Book: Why Price-Time Priority Is Non-Negotiable
The order book is the heart of the exchange. It holds all resting orders — bids and asks. The matching rule is simple: price-time priority. Highest bid gets matched first; if equal price, earliest order wins. Same for asks: lowest price first, then time. This is the only fair algorithm. FIFO queues per price level are the standard implementation. Don't use a single sorted list — that's O(n) insert. Use a map from price to a queue (or a priority queue per side). For production, use a concurrent skip list or a custom lock-free structure. The naive approach with a single lock will kill your throughput.
OrderBook.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// io.thecodeforge — System Design tutorialimport java.util.*;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.locks.ReentrantLock;
publicclassOrderBook {
// Price -> Queue of orders. ConcurrentSkipListMap for sorted iteration.privatefinalConcurrentSkipListMap<Long, Deque<Order>> bids = newConcurrentSkipListMap<>(Collections.reverseOrder());
privatefinalConcurrentSkipListMap<Long, Deque<Order>> asks = newConcurrentSkipListMap<>();
private final ReentrantLock lock = new ReentrantLock(); // coarse lock, will improve laterpublicvoidaddOrder(Order order) {
lock.lock();
try {
var side = order.side == Side.BUY ? bids : asks;
side.computeIfAbsent(order.price, k -> newArrayDeque<>()).addLast(order);
} finally {
lock.unlock();
}
}
publicOrdergetBestBid() {
lock.lock();
try {
var entry = bids.firstEntry();
return entry != null ? entry.getValue().peekFirst() : null;
} finally {
lock.unlock();
}
}
// ... matching logic
}
enumSide { BUY, SELL }
classOrder {
long id;
Side side;
long price; // in cents to avoid floating pointlong quantity;
long timestamp;
volatileStatus status = Status.ACTIVE;
}
enumStatus { ACTIVE, CANCELLED, MATCHED }
Output
No output — this is a data structure definition.
Production Trap: Floating Point Prices
Never use double or float for prices. Rounding errors accumulate. Use long with a fixed decimal multiplier (e.g., cents). I've seen a trading system lose $50k due to a rounding error in a single trade.
thecodeforge.io
Stock Exchange Order Matching Engine Design
Design Stock Exchange
Matching Logic: The Critical Path Where Milliseconds Matter
When a new order arrives, the matching engine checks if it can trade against the opposite side's best price. For a buy order, it looks at the best ask. If the buy price >= ask price, a trade occurs at the ask price. The order with the earlier timestamp gets priority. The match reduces the quantity of both orders. If one order is fully filled, it's removed from the book. The remaining quantity continues matching against the next best price. This loop must be fast and lock-free. The classic mistake is to hold a lock across the entire matching loop — that serializes all orders. Instead, use per-price-level locks or optimistic locking. The code below shows a simplified matching loop with a coarse lock for clarity, but in production you'd use a lock-free approach.
MatchingEngine.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// io.thecodeforge — System Design tutorialpublicclassMatchingEngine {
privatefinalOrderBook book = newOrderBook();
publicList<Trade> matchOrder(Order incoming) {
List<Trade> trades = newArrayList<>();
book.lock.lock();
try {
var oppositeSide = incoming.side == Side.BUY ? book.asks : book.bids;
var bestPrice = oppositeSide.firstEntry();
while (bestPrice != null && canTrade(incoming, bestPrice.getKey())) {
var queue = bestPrice.getValue();
var resting = queue.peekFirst();
while (resting != null && incoming.quantity > 0) {
long tradeQty = Math.min(incoming.quantity, resting.quantity);
long tradePrice = resting.price; // always at resting price
trades.add(newTrade(incoming.id, resting.id, tradePrice, tradeQty));
incoming.quantity -= tradeQty;
resting.quantity -= tradeQty;
if (resting.quantity == 0) {
queue.pollFirst();
resting = queue.peekFirst();
}
}
if (queue.isEmpty()) {
oppositeSide.pollFirstEntry();
}
bestPrice = oppositeSide.firstEntry();
}
if (incoming.quantity > 0) {
book.addOrder(incoming);
}
} finally {
book.lock.unlock();
}
return trades;
}
privatebooleancanTrade(Order incoming, long oppositePrice) {
return incoming.side == Side.BUY ? incoming.price >= oppositePrice : incoming.price <= oppositePrice;
}
}
classTrade {
long buyOrderId, sellOrderId, price, quantity;
Trade(long buy, long sell, long price, long qty) { this.buyOrderId = buy; this.sellOrderId = sell; this.price = price; this.quantity = qty; }
}
Output
No output — matching engine logic.
Senior Shortcut: Pre-Allocate Trade Lists
Concurrency: Lock-Free Order Books for Sub-Millisecond Latency
The coarse lock in the previous section is a bottleneck. In production, you need lock-free data structures. The standard approach is a concurrent skip list for price levels, and each price level has a concurrent linked queue (e.g., Michael-Scott queue). For order status, use AtomicReference or volatile with CAS. The matching loop becomes: atomically dequeue the best resting order, CAS its status from ACTIVE to MATCHED. If CAS fails (order was cancelled), retry with the next order. This avoids locks entirely. The downside is complexity — you must handle ABA problems and memory reclamation. For most systems, per-price-level locks are a good compromise: one lock per price level, not one global lock.
LockFreeOrderBook.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// io.thecodeforge — System Design tutorialimport java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.atomic.AtomicReference;
publicclassLockFreeOrderBook {
privatefinalConcurrentSkipListMap<Long, ConcurrentLinkedDeque<Order>> bids = newConcurrentSkipListMap<>(java.util.Collections.reverseOrder());
privatefinalConcurrentSkipListMap<Long, ConcurrentLinkedDeque<Order>> asks = newConcurrentSkipListMap<>();
publicvoidaddOrder(Order order) {
var side = order.side == Side.BUY ? bids : asks;
var queue = side.computeIfAbsent(order.price, k -> newConcurrentLinkedDeque<>());
queue.addLast(order);
}
publicOrdergetBestBid() {
var entry = bids.firstEntry();
if (entry == null) returnnull;
return entry.getValue().peekFirst();
}
// Matching: atomically dequeue and CAS statuspublicbooleantryMatch(Order incoming) {
var oppositeSide = incoming.side == Side.BUY ? asks : bids;
var entry = oppositeSide.firstEntry();
while (entry != null && canTrade(incoming, entry.getKey())) {
var queue = entry.getValue();
Order resting = queue.peekFirst();
while (resting != null) {
if (resting.status.compareAndSet(Status.ACTIVE, Status.MATCHED)) {
// matchedlong tradeQty = Math.min(incoming.quantity, resting.quantity);
incoming.quantity -= tradeQty;
resting.quantity -= tradeQty;
if (resting.quantity == 0) {
queue.pollFirst(); // remove from queue
}
return true; // simplified: one match per call
} else {
// order was cancelled or already matched, skip
queue.pollFirst();
resting = queue.peekFirst();
}
}
oppositeSide.pollFirstEntry();
entry = oppositeSide.firstEntry();
}
returnfalse;
}
}
// Order with AtomicReference for statusclassOrder {
long id;
Side side;
long price;
volatilelong quantity;
finalAtomicReference<Status> status = newAtomicReference<>(Status.ACTIVE);
// ...
}
Output
No output — lock-free structure.
The Classic Bug: ABA Problem in Lock-Free Queues
thecodeforge.io
Lock-Free vs. Lock-Based Order Books
Design Stock Exchange
Market Data: Publishing Trades and Order Book Snapshots
Every trade and every change to the order book must be published to market data feeds. This includes trade prints (price, quantity, time) and order book depth updates (top of book or full depth). The publisher must be fast and lossless. Use a multicast UDP feed for low latency, with a TCP recovery channel for missed packets. For the publisher, use a disruptor pattern (ring buffer) to avoid locks. The matching engine writes to the ring buffer, and the publisher thread reads and sends. Never block the matching engine on network I/O. If the network is slow, drop packets (with sequence numbers) rather than block the matching engine.
An order goes through several stages: validation (check account balance, symbol, price limits), risk checks (position limits, circuit breakers), matching, trade reporting, and finally settlement. Each stage is a separate service. The matching engine is just one part. The order management system (OMS) handles the lifecycle. Use a persistent queue (Kafka) between stages for durability. The matching engine should be stateless — it reads orders from a queue and writes trades to another queue. This allows replay and recovery. Never store state in the matching engine itself; use a database for the order book snapshot.
Before an order reaches the matching engine, it must pass risk checks. This includes: max order size, max position, price collars (no trades outside a band), and circuit breakers (halt trading if price moves too fast). These checks are critical — a fat-finger error can wipe out a firm. The risk service must be fast (sub-millisecond) because it's in the critical path. Use in-memory state with a write-ahead log for recovery. The classic mistake is to do risk checks in the matching engine itself, which adds latency and couples concerns.
RiskService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// io.thecodeforge — System Design tutorialimport java.util.concurrent.ConcurrentHashMap;
publicclassRiskService {
privatefinalConcurrentHashMap<String, Long> positionLimits = newConcurrentHashMap<>();
privatefinalConcurrentHashMap<String, Long> currentPositions = newConcurrentHashMap<>();
publicbooleancheckOrder(Order order) {
// Check price collar: no more than 10% away from last tradelong lastTradePrice = getLastTradePrice(order.symbol);
if (Math.abs(order.price - lastTradePrice) > lastTradePrice * 0.1) {
return false; // price too far
}
// Check position limitString key = order.traderId + ":" + order.symbol;
long current = currentPositions.getOrDefault(key, 0L);
long newPosition = order.side == Side.BUY ? current + order.quantity : current - order.quantity;
long limit = positionLimits.getOrDefault(key, Long.MAX_VALUE);
if (Math.abs(newPosition) > limit) {
returnfalse;
}
returntrue;
}
privatelonggetLastTradePrice(String symbol) { /* from market data */ return0; }
}
Output
No output — risk check logic.
Never Do This: Risk Checks in the Matching Engine
Persistence and Recovery: Don't Lose a Single Order
The matching engine must be durable. Every order and trade must be logged before it's processed. Use a write-ahead log (WAL) on a fast SSD. The log is the source of truth. The in-memory order book is rebuilt from the log on restart. For high throughput, batch log writes (e.g., every 1ms or 1000 orders). The trade-off: if the engine crashes between batches, you lose up to 1ms of orders. That's acceptable for most exchanges. For ultra-low-latency, use a replicated log (e.g., Kafka) with synchronous replication.
When Not to Use This: Simpler Alternatives for Smaller Systems
If you're building a crypto exchange for a small user base, you don't need lock-free data structures or a disruptor. A single-threaded matching engine with a simple lock can handle 10k orders per second. Use a relational database for the order book if latency isn't critical. The complexity of a full exchange design is justified only when you need sub-millisecond latency and high throughput. For a demo or internal tool, keep it simple. I've seen teams over-engineer and create more bugs than they solved.
When to Keep It Simple
● Production incidentPOST-MORTEMseverity: high
The Double-Fill That Almost Broke the Clearing House
Symptom
A single market order to buy 1000 shares of XYZ was filled twice — once against two different sell orders — resulting in 2000 shares bought. The trader's account went negative.
Assumption
The team assumed a network retry caused a duplicate order submission.
Root cause
The matching engine used a ReadWriteLock on the order book. During a concurrent cancel and match, the cancel thread released the lock after removing the order, but the match thread had already read the order as valid. The match proceeded with a stale reference, filling an already-cancelled order.
Fix
Switched to a lock-free order book using atomic compare-and-swap on order status. Each order has a volatile 'status' field. Before matching, the engine atomically sets status to 'matched'. If it's already 'cancelled', the match is aborted.
Key lesson
Never trust a lock to protect a read-then-write sequence across threads.
Use atomic state transitions on the order itself.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
Order book out of sync between matching engine and backup
→
Fix
1. Stop the matching engine. 2. Replay the WAL from the last consistent snapshot. 3. Compare order book state with backup. 4. Restart with correct state.
Symptom · 02
Market data feed missing trades
→
Fix
1. Check sequence numbers on the feed. 2. Request retransmission from the publisher. 3. Verify the publisher's ring buffer isn't dropping events. 4. Increase buffer size if needed.
Symptom · 03
Matching engine latency spikes every 30 seconds
→
Fix
1. Check GC logs — likely a stop-the-world GC. 2. Switch to a low-pause GC (ZGC or Shenandoah). 3. Reduce allocation rate by reusing objects.
★ Stock Exchange Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
Order not matched when it should be — `Order ID 12345 not found in book`−
Immediate action
Check if order was cancelled or already filled.
Commands
grep '12345' /var/log/matching-engine.log
curl http://localhost:8080/order/12345
Fix now
If cancelled, resubmit. If filled, check trade report.
Duplicate trade — `Trade ID 67890 appears twice`+
Immediate action
Check idempotency keys.
Commands
grep '67890' /var/log/order-submission.log
SELECT * FROM trades WHERE trade_id=67890
Fix now
Delete duplicate trade and adjust positions. Add idempotency check in submission service.
Market data feed stale — last update 5 seconds ago+
Immediate action
Check publisher health.
Commands
curl http://localhost:8081/health
tail -100 /var/log/market-data-publisher.log
Fix now
Restart publisher. Check ring buffer drop rate. Increase buffer size.
Matching engine CPU at 100% — orders queuing up+
Immediate action
Check for infinite loop or GC thrash.
Commands
top -H -p $(pgrep matching-engine)
jstack <pid> | grep -A 30 'Runnable'
Fix now
If infinite loop, kill and restart. If GC, increase heap or switch to ZGC.
Aspect
Coarse Lock
Per-Price-Level Lock
Lock-Free
Throughput
~10k orders/sec
~100k orders/sec
~1M orders/sec
Latency (p99)
~100μs
~10μs
~1μs
Complexity
Low
Medium
High
Risk of bugs
Low
Medium
High
Best for
Prototypes, low volume
Most production exchanges
High-frequency trading
Key takeaways
1
Price-time priority is the only fair matching algorithm
never compromise on it.
2
Lock-free data structures are for ultra-low latency; per-price-level locks are sufficient for most systems.
3
Always make order processing idempotent
use unique order IDs and check for duplicates.
4
Separate risk checks from the matching engine to avoid latency coupling.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
How does the matching engine handle concurrent cancellations and matches...
Q02SENIOR
When would you choose a lock-free order book over a per-price-level lock...
Q03SENIOR
What happens when a market order arrives and there's not enough liquidit...
Q04JUNIOR
Explain the order lifecycle from submission to settlement.
Q05SENIOR
You notice that the matching engine's latency spikes every 30 seconds. W...
Q06SENIOR
Design a system that can handle 1 million orders per second with sub-mil...
Q01 of 06SENIOR
How does the matching engine handle concurrent cancellations and matches without data races?
ANSWER
Use atomic state transitions on the order (CAS from ACTIVE to MATCHED or CANCELLED). Before matching, atomically set status to MATCHED. If CAS fails, the order was cancelled or already matched — skip it. This avoids locks and ensures correctness.
Q02 of 06SENIOR
When would you choose a lock-free order book over a per-price-level lock?
ANSWER
Lock-free is for ultra-low latency (sub-microsecond) and high throughput (millions of orders/sec). Per-price-level locks are simpler and sufficient for most exchanges (100k orders/sec). Choose lock-free only if you need the absolute best performance and can tolerate the complexity.
Q03 of 06SENIOR
What happens when a market order arrives and there's not enough liquidity? How do you prevent the order from walking the book too far?
ANSWER
The order matches against the best available prices until filled or the book is exhausted. To prevent price slippage, exchanges implement price collars or limit the number of price levels a market order can cross. Alternatively, convert the remaining quantity to a limit order at the last traded price.
Q04 of 06JUNIOR
Explain the order lifecycle from submission to settlement.
ANSWER
Order is submitted → validated (symbol, account) → risk checked (position limits, price collars) → matched by the engine → trade reported → sent to clearing house for settlement. Each step is a separate service communicating via persistent queues for durability.
Q05 of 06SENIOR
You notice that the matching engine's latency spikes every 30 seconds. What do you do?
ANSWER
Check GC logs. Likely a stop-the-world GC. Switch to a low-pause GC like ZGC or Shenandoah. Also check for periodic batch operations (e.g., flushing WAL) that might block the engine. Move flushing to a separate thread.
Q06 of 06SENIOR
Design a system that can handle 1 million orders per second with sub-millisecond latency.
ANSWER
Use lock-free data structures (concurrent skip list + Michael-Scott queue). Single-threaded matching per symbol to avoid contention. Use a disruptor for market data publishing. Write-ahead log with batch flushing. Kernel bypass (DPDK) for networking. Separate risk checks into a different service.
01
How does the matching engine handle concurrent cancellations and matches without data races?
SENIOR
02
When would you choose a lock-free order book over a per-price-level lock?
SENIOR
03
What happens when a market order arrives and there's not enough liquidity? How do you prevent the order from walking the book too far?
SENIOR
04
Explain the order lifecycle from submission to settlement.
JUNIOR
05
You notice that the matching engine's latency spikes every 30 seconds. What do you do?
SENIOR
06
Design a system that can handle 1 million orders per second with sub-millisecond latency.
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
How does a stock exchange match buy and sell orders?
It uses a price-time priority algorithm. The highest bid and lowest ask are matched first. If prices are equal, the earliest order gets priority. The matching engine continuously checks incoming orders against the order book and executes trades when prices cross.
Was this helpful?
02
What's the difference between a limit order and a market order?
A limit order specifies a price and will only trade at that price or better. A market order trades immediately at the best available price. Market orders guarantee execution but not price; limit orders guarantee price but not execution.
Was this helpful?
03
How do I build a high-frequency trading matching engine?
Use lock-free data structures, kernel bypass networking (DPDK), a single-threaded event loop per symbol, and a write-ahead log with batch flushing. Minimize allocations to avoid GC. Use a disruptor for market data publishing.
Was this helpful?
04
What happens if the matching engine crashes? How is state recovered?
The matching engine logs every order and trade to a write-ahead log (WAL). On restart, it replays the WAL to rebuild the in-memory order book. The WAL is the source of truth. For high availability, use a replicated log (Kafka) and a hot standby.