Limit Order Book & Matching Engine

A low-latency matching engine written from scratch in modern C++ using price-time priority. Includes a cache-conscious flat price ladder, order nodes from a custom pool allocator, cancels/modifies through an open-addressing hash map, and a lock-free SPSC ring buffer feeding a separate engine thread. The single-threaded core is compiled to WebAssembly and runs live below; performance numbers and benchmarks come from the native build.

Initializing…

Order Entry

Side
Speed
Best Bid
Best Ask
Spread
Resting Orders
Engine Ops0

Trade Tape

How Matches Work

1

Receive

An order arrives (limit, market, or IOC). In the native build, a feed-handler thread decodes it and provides it to the engine over a lock-free queue.

2

Match

An aggressive order walks the opposite side best-price-first, FIFO within each price, emitting trades until it is filled or the book can’t improve.

3

Remainder

Any unfilled remainder of a limit order joins the tail of its price level, taking a time-priority ticket behind everything already there.

4

Report

Fills go out as trades; cancels and modifies find their order in O(1) through the id → node map and unlink it.

Price-time priority

Better prices trade first, and at the same price, the order that arrived first trades first.

buy limit @ 100.03, qty 500 ── walks the ask side ──► ask 100.05 | 200 left untouched (worse price) ask 100.04 | 150 left untouched (worse price) ask 100.03 | 80 #1 oldest ─► fill 80 (price ok, FIFO first) ask 100.03 | 120 #2 ─► fill 120 ask 100.03 | 90 #3 ─► fill 90 → 290 filled residual 210 rests as the new best bid @ 100.03

Book as a flat price ladder

Prices live on a discrete tick grid, so the book is one contiguous array indexed directly by price. Best bid/ask and any level are O(1) array lookups into cache-resident memory. The alternative, std::map<Price, Level>, is a red-black tree (O(log n)) whose every node is a separate heap allocation reached by pointer, which leads to a cache miss per level touched.

Flat ladder (this engine)

levels[10003] ─► [FIFO] levels[10004] ─► [FIFO] levels[10005] ─► [FIFO] contiguous · O(1) 1 cache line
vs

std::map<Price,Level>

(root) / \ (node) (node) pointer-chased tree scattered · O(log n) miss per hop

Intrusive FIFO levels + pool allocator

Each price level is an intrusive doubly-linked list (the order carries its own prev/next, so a level needs no per-node container allocation). The nodes themselves come from a fixed pool (one contiguous slab, with memory handed out from a free list) so adds and cancels never call malloc on the hot path and neighboring orders stay close in memory.

struct Order { // the node is the order (no wrapper) OrderId id; Side side; Price price; Qty qty; // remaining, unfilled Order* prev; Order* next; }; level.head ─►[ id 7 · qty 80 ]◄─►[ id 9 · qty 120 ]◄─►[ id 12 · qty 90 ]◄─ level.tail
Pool<Order>: [■■■■□□□□□□□□] slab + free list allocate() → pop a free slot (O(1), no malloc, no lock) deallocate() → push it back (O(1), instant reuse)

Lock-free feed handler

One thread decodes the inbound feed and the other runs the engine. They interact via a single-producer / single-consumer ring buffer, using two atomic counters with acquire/release ordering. The head and tail sit on separate cache lines so the producer's writes don't invalidate the consumer's line. This part is native-only (browser WASM is single-threaded).

buf_ — capacity 8, mask_ = 7 idx 0 1 2 3 4 5 6 7 [ ][ ][ 43 ][ 44 ][ ][ ][ ][ ] ▲ ▲ head tail push(v) — producer: buf_[tail & mask_] = v; tail_.store(tail+1, release) pop(out) — consumer: out = buf_[head & mask_]; head_.store(head+1, release) live count = tail − head (indices only ever grow; wraparound is just & mask_)

Native Benchmarks

Measured on the native build (the browser never runs these). Latency is per-message, single threaded; throughput and the lock-free comparison use the threaded feed handler.

Per-operation latency distribution (native, nanoseconds)
Throughput: lock-free SPSC vs std::mutex queue (M msg/s, higher is better)
Node allocation: pool vs new/delete (ns/op, lower is better)
Level access: flat ladder vs std::map (ns/op, lower is better)
Order lookup: FlatHash vs std::unordered_map (ns/op, lower is better)

Engineering Decisions

Data structure choices

  • Flat price ladder over std::map: a book clusters near the mid, so a direct-indexed array keeps hot levels in cache and makes best-price and level access O(1) and doesn't use pointer hopping
  • Pool allocator over new/delete: the hot path allocates a node per add and frees one per fill/cancel; a slab + free list makes both O(1) and more cache-friendly
  • Open-addressing map over std::unordered_map: cancels/modifies look up by id frequently, so a flat linear-probed table is useful to avoid a chained map's per-entry allocation and pointer hop
  • Lock-free SPSC over a mutex queue: exactly one producer and one consumer means the ingest needs only two atomics with acquire/release

Limitations

  • Live demo runs single-threaded in WebAssembly (browser WASM has no shared-memory threads without special headers GitHub Pages can't set), so this project only uses the lock-free feed handler in the native benchmarks
  • Order feed is synthetically generated rather than a real exchange protocol (ITCH/OUCH/FIX)
  • Best-price search uses a short linear scan of the ladder rather than a bitset/SIMD index (fine while the book is concentrated, but not asymptotically ideal)