Skip to content
Aman SinghWorkshop
Mini-JavaScript-Projects / Pocket Matching Engine

Pocket Matching Engine

A limit order book that matches buys and sells by price-time priority, the rule most stock exchanges use, with a dashboard that shows every fill as it happens. The matching core is correct for whole-number quantities and handles more than a million random orders a second.

980Lines total
296Engine lines
O(n)Insert cost
1.29MOrders/s, 10k random
6 / 6Scenarios passed
Demo

A four-level sweep

Dashboard with four resting bids from 99.00 to 99.50 and three asks from 100.50 to 101.00, spread one dollar
Before. Four bids and three asks. The two bids at $99.50 are listed #1 then #4, which is time priority at work. Spread is $1.00.
Dashboard after a sell of 40 at 99.00 filled four bids, trade log showing four executed trades
After a sell of 40 @ $99.00. It fills #1 and #4 at $99.50, #2 at $99.25, and 5 of #3 at $99.00. Four trades, volume 40, spread widens to $1.50.
Run Demo. Submits buy 10 @ 100, buy 5 @ 100 and sell 12 @ 100, 500 ms apart, through the real input fields. The sell fills #1 completely and 2 of #2.
The same sweep result in dark mode
Dark mode. Follows the system setting and remembers a manual toggle in localStorage.
Architecture

Engine and UI, cleanly apart

engine.js holds three classes: Order, Trade and MatchingEngine. It touches no DOM, which is why the unmodified file could be loaded into Node and benchmarked. ui.js validates input, calls the engine and re-renders both books, the trade log and a stats bar.

Each side of the book is one plain array kept in priority order. Bids sort by price high to low, asks by price low to high, and ties sort by timestamp. The best price is always at index 0.

FileLinesRole
js/engine.js296Order book, matching, spread, volume
js/ui.js328Input, rendering, demo, theme, keys
index.html346Three-column layout, Tailwind config
favicon.svg10Icon
Submitbutton or Enter Validateqty > 0, price > 0 new Orderid++, price 2 dp, Date.now() Crosses bestopposite? yes Trademin(qty) at theresting order's price filled resting order: shift() off, loop no, or filled Rest remainderscan for slot, splice in · O(n) UI renderlog cards, both books, stats BOOK LAYOUT AFTER THE "BEFORE" SCREENSHOT #1 10@99.50bid[0] #4 5@99.50bid[1] #2 20@99.25 #3 15@99.00 #5 10@100.50ask[0] #6 25@100.75 #7 8@101.00 $1.00
Order lifecycle. An incoming order walks the opposite side from index 0 until prices stop crossing. Each trade prints at the resting order's price, so a buy at $101 against an ask at $99 fills at $99. Whatever is left is inserted at its price-time position.
How it works

The match loop and the insert

// engine.js:102-116 · matching an incoming buy
while (buyOrder.quantity > 0 && this.sellOrders.length > 0) {
  const bestSell = this.sellOrders[0];
  if (buyOrder.price < bestSell.price) break;
  const tradeQty = Math.min(buyOrder.quantity, bestSell.quantity);
  const tradePrice = bestSell.price;   // maker's price
  ...
}

The sell side mirrors this with the comparison reversed.

// engine.js:201-216 · resting a bid
for (let i = 0; i < this.buyOrders.length; i++) {
  const existing = this.buyOrders[i];
  if (order.price > existing.price) { insertIndex = i; break; }
  if (order.price === existing.price &&
      order.timestamp < existing.timestamp) { insertIndex = i; break; }
}
this.buyOrders.splice(insertIndex, 0, order);

First-in-first-out at a price, as long as the wall clock never steps back.

OperationCostWhy
Insert a resting orderO(n)Linear scan, then splice shifts the tail
Remove a filled head orderO(n)Array.shift() moves every element
Sweep producing k fillsO(k·n)One shift per full fill
Best bid / ask, spreadO(1)Index 0 of each side
Total volumeO(trades)Recomputed on every UI update
Measurements

Throughput, and where it falls off

Orders processed per second, engine only
Median of 5 runs, Node 24 on Apple M5. "No-cross" places every order at the worst price for its side so each insert scans the whole book. 100k no-cross was not run.

Random flow (prices around $100 with a $2 spread of values, sizes 1 to 100) keeps about 21% of orders resting. Throughput drops from 1.3M to 0.5M orders a second as the book deepens to 21,523 orders.

The adversarial case is quadratic. Going from 10k to 50k orders (5×) took 30.6× longer: 1.78 seconds for 50k orders. That matters only for a deep book, and the UI adds a full re-render of both tables on every order anyway.

Orders left resting after random flow
Correctness scenarioResult
Buy 3 @ 101 against asks at 101, 99, 100Pass fills at 99, then 100, then 101
Two bids at 100, then sell 7 @ 100Pass first bid fills 5, second fills 2 and rests as partial
Maker price rule, both sidesPass ask 99 hit by buy 101 prints 99; bid 101 hit by sell 99 prints 101
Incoming partial fillPass remainder of 3 rests with original quantity 8
No crossPass both rest, spread $1.00
Random invariants, 170k+ ordersPass sorted, never crossed, quantity conserved