Published on August 2026 • 9 min read
I wanted to understand what actually happens after someone clicks “Buy” on an exchange, so I tried building one from scratch.

When I started building my centralized exchange, I wanted to understand what actually happens after someone clicks Buy.
How does an order enter the market? How does the engine decide which orders should trade? How are balances locked and settled? And what happens to the market state if the process crashes?
Those questions led me to build the exchange layer from scratch in TypeScript.
The exchange layer is responsible for a single SOL-USD market and handles order placement, matching, balances, settlement, persistence, recovery, and market events.
The core idea was to keep the matching path simple and predictable.
Market state lives in memory, while commands that mutate that state are persisted separately.
At the center of the system is the MarketRuntime, which receives commands through a single-writer queue and coordinates the different parts of the exchange.

The main flow is
Command
↓
CommandQueue
↓
MarketRuntime
↓
OrderPlacementService
├── MatchingEngine
├── BalanceService
└── Order State
↓
WAL / SnapshotsMarket events are published separately through an event bus and exposed through SSE.
The important architectural decision here is that the order book isn’t being mutated by multiple parts of the application independently.
There is one place where market commands are processed and state changes happen.
An order book is inherently stateful.
Imagine two orders arriving at nearly the same time and both trying to consume the same resting order.
With concurrent mutation, I’d have to introduce synchronization throughout the order book and balance system.
Instead, I chose a simpler model.
All market-changing commands enter a CommandQueue and are processed sequentially by the market runtime.
Order A ─┐
Order B ─┼──→ CommandQueue → MarketRuntime → State Mutation
Order C ─┘This gives the market a deterministic ordering of state changes.
It also simplifies recovery.
If the same initial state receives the same sequence of commands, the engine should arrive at the same resulting state.
// MarketRuntime — every mutation enters here
place(order: Order): Promise<PlacementResult> {
return this.enqueue(() => this.placeNow(order));
}
private enqueue<T>(run: () => T): Promise<T> {
return this.queue.enqueue(run);
}
// CommandQueue.pump — single writer: jobs run one-by-one
for (const job of batch) {
finished.push({ job, ok: true, value: job.run() }); // → placeNow
}The important part of this code isn’t the queue implementation itself.
It’s the boundary it creates:
External requests submit commands. The market runtime owns the actual mutation of market state.
The easiest way to understand the exchange is to follow one order.
Suppose a user submits:
BUY
2 SOL
LIMIT
$100
GTCThe order doesn’t immediately modify the order book.
It moves through the market runtime and eventually reaches the placement service.
The lifecycle looks roughly like this:
HTTP Request
↓
NEW Order
↓
CommandQueue
↓
Validation
↓
Lock Funds
↓
FOK Precheck
↓
Match
↓
Settle Fills
↓
Remaining Quantity?
↙ ↘
YES NO
↓ ↓
RESTING FILLEDThe interesting part is that these responsibilities aren’t all implemented inside the matching engine.
The placement layer coordinates the operation.
The matcher has a much smaller responsibility:
this.lockOrder(order);
const { trades, taker } = this.matcher.match(order, book);
this.settleAndLogFills(trades, book, taker);Find crossing orders and produce fills.
It doesn’t handle balances.
It doesn’t decide whether an order is IOC or FOK.
It doesn’t perform settlement.
That separation keeps the matching logic focused on the market itself.
The order book uses price-time priority.
The best available price gets priority, and orders at the same price are matched FIFO.
Conceptually:
Bids Asks
$101 → [Order A, B] $102 → [Order C]
$100 → [Order D, E] $103 → [Order F]
$99 → [Order F] $104 → [Order G]For a buy order, the engine starts with the lowest ask.
For a sell order, it starts with the highest bid.
If the incoming order crosses that price, a fill can happen.
One detail I wanted to make explicit was the execution price.
Trades execute at the resting maker price.
For example:
SELL 5 SOL @ $98followed by:
BUY 5 SOL @ $100results in a trade at:
$98The buyer’s $100 limit determines whether the orders can cross. The resting order determines the execution price.
while (remaining(taker) > 0) {
const best = isBuy ? book.getBestAsk() : book.getBestBid();
if (!best) break;
const maker = best.priceLevel.peekFirst();
if (!maker) break;
const qty = Math.min(remaining(taker), remaining(maker));
taker.filledQuantity += qty;
book.applyFill(maker.orderId, qty);
}The matcher only deals with price crossing, quantities, and consuming resting orders. The surrounding placement layer handles everything else.
This separation became useful as more order types and balance rules were added.
Matching orders is only half of an exchange.
The engine also has to prevent users from spending the same balance twice.
Each asset balance is represented using:
available
lockedWhen an order is placed, the required funds move from available to locked.
Those funds remain reserved until the order is filled, cancelled, or otherwise released.
Consider:
BUY 10 SOL @ $100The engine initially locks:
$1,000Now suppose those 10 SOL are filled at the maker price of $98.
The actual cost is:
10 × $98 = $980The remaining $20 is unlocked as price improvement.
This is a small example, but it demonstrates why an exchange isn’t just an order-matching problem.
Every fill also has corresponding balance transitions.
The engine keeps an append-only ledger with explicit reasons such as:
DEPOSIT
LOCK_ORDER
UNLOCK_ORDER
SETTLE_DEBIT
SETTLE_CREDIT
WITHDRAWThat gives each balance mutation a reason instead of treating the current balance as an unexplained number.
The engine currently supports:
These rules deliberately don’t live inside the matching algorithm.
For example, an FOK order must be completely fillable before it mutates the book.
If a user wants to buy 10 SOL but only 7 SOL of suitable liquidity exists:
Required: 10 SOL
Available: 7 SOL
→ FOK fails
→ Book remains unchangedAn IOC order can consume whatever liquidity is immediately available and cancel the remainder.
Market orders don’t have a limit price. For market buys, the engine uses a quoteBudget and stops when the remaining budget can't purchase another whole lot.
The placement layer coordinates these rules around the matcher rather than making the matcher responsible for all of them.
Keeping the order book in memory makes the matching path straightforward.
But it creates an obvious problem:
What happens when the process restarts?
Losing the entire market state obviously isn’t acceptable.
So I introduced a write-ahead log.
The engine persists the commands that mutate market state:
private placeNow(order: Order): PlacementResult {
const snapshot = cloneOrder(order);
const result = this.placement.place(order, this.book);
this.persist({
type: "PLACE",
order: snapshot,
timestamp: snapshot.timestamp,
})The important decision here is that the WAL is a command log, not a trade log.
Trades are derived from matching.
The original commands are the inputs that produced those trades.
That means the engine can reconstruct market state by replaying the same commands through the same deterministic state-transition logic.
The WAL isn’t simply a backup.
It’s part of the recovery mechanism.
this.queue = new CommandQueue(() => this.wal.flush());
// CommandQueue.pump
const batch = this.jobs.splice(0, this.jobs.length);
for (const job of batch) {
finished.push({ job, ok: true, value: job.run() }); //mutate + wal.append
}
await this.afterBatch(); // one fsync for the batchThe queue drains a batch of commands, appends their WAL entries, and then performs a single fsync for the batch.
This is the basic idea behind the engine’s group-commit approach:
PLACE/CANCEL/CREDIT
↓
drain batch
↓
for each: mutate + append WAL
↓
one fsyncThe goal is to get durability without forcing every individual command to perform its own filesystem synchronization.
Replaying the entire WAL on every restart would eventually become expensive.
So the engine periodically creates snapshots.
The current checkpoint interval is every 1024 commands.
Recovery then looks like:
Snapshot
↓
Restore RAM State
↓
Read snapshot.walSeq
↓
Replay WAL entries
where seq > snapshot.walSeq
↓
Market LiveThe snapshot provides a known state, while the WAL contains everything that happened after it.
This gives a simple recovery model:
Restore the latest snapshot, then replay only the WAL tail.
Snapshots introduce a straightforward trade-off.
More frequent snapshots reduce recovery time but increase snapshot overhead.
Less frequent snapshots reduce that overhead but leave more commands to replay.
For the current engine, 1024 commands is the checkpoint policy I’ve chosen.
The WAL design only works cleanly if replay is deterministic.
The property I’m aiming for is simple:
Same initial state + Same command sequence = Same final stateThat’s another reason the single-writer model matters.
The engine processes commands in a defined order, and those commands are the durable inputs to the market.
On restart, the process isn’t trying to reconstruct a collection of trades and somehow infer what the order book looked like.
It simply rebuilds the state by running the same commands again.
const snapshot = loadSnapshot(snapshotPath);
runtime.replay(snapshot?.walSeq ?? 0, snapshot);
// replay()
if (snapshot) {
this.placement.restoreSnapshot(snapshot, this.book);
this.wal.adoptSeq(snapshot.walSeq);
}
for (const command of this.wal.readAfter(afterSeq)) {
this.apply(command); // only seq > afterSeq
}The important part to highlight in this snippet is where the engine ignores WAL entries that are already represented by the snapshot and only replays commands with:
seq > snapshot.walSeqThat sequence number gives the snapshot and WAL a clear boundary.
The engine also needs to communicate state changes to clients.
Rather than making SSE responsible for market logic, the engine publishes events through an EventBus.
The current event stream includes:
ORDER
BBO
CREDITThe flow is
Market State Change
↓
EventBus
↓
SSE
↓
ClientsSSE is therefore an observation mechanism.
It doesn’t participate in the market’s state mutation path.
This keeps the core engine independent from how clients consume updates.
A matching engine isn’t particularly useful if the happy path works but the state becomes inconsistent after an edge case.
So the tests focus heavily on state transitions.
The exchange has tests across three levels.
Unit tests cover things such as:
Integration tests cover persistence and WAL replay.
End-to-end tests exercise the exchange through its HTTP API.
Some of the most valuable tests aren’t checking HTTP responses.
They’re checking invariants.
A failed FOK should leave the book unchanged.
A cancelled order should release its locked funds.
A filled order should settle both sides correctly.
A restart followed by WAL replay should reconstruct the same market state.
For a stateful system, those guarantees matter much more than whether a particular endpoint returned 200.
The exchange layer is deliberately isolated from the rest of the application.
The current engine focuses on:
The application around it can evolve independently.
That separation is useful because the exchange engine doesn’t need to know how a user authenticated, where a wallet came from, or how the frontend renders the order book.
Those concerns can sit outside the engine and communicate with it through a well-defined boundary.
The next stage of the project will be connecting that boundary to the rest of the application, but the exchange layer itself has its own responsibilities and invariants.
The matching loop was actually one of the simpler parts.
The harder problems were everything around it.
How do you serialize state mutations?
How do you prevent balances from being spent twice?
How do you make an in-memory market durable?
What exactly should go into a WAL?
How do you recover the market after a crash?
How do you make replay deterministic?
And how do you keep the matching logic from becoming responsible for every other part of the system?
Those questions pushed the design toward a few principles:
None of these ideas are particularly exotic on their own.
What I found valuable was seeing how they interact when you’re responsible for implementing the state transitions yourself.
Building the exchange layer changed how I think about trading systems.
Before starting the project, I thought the interesting part would mostly be the order book and matching algorithm.
The matching algorithm is only one piece.
The real engineering is making the entire state transition reliable:
command
↓
validate
↓
lock
↓
match
↓
settle
↓
persist + publishOnce that works, you also need to be able to restart the process and arrive at the same state.
That was probably the biggest takeaway from building this.
You stop thinking about the exchange as just an order book and start thinking about every operation as a state transition that needs to be correct, durable, and recoverable.