Build a Crypto Exchange Matching Engine
Build a real-time order matching engine with WebSocket streaming.
Introduction
30 Second Summary
When you place a trade on Coinbase or Binance, your order does not just float into the void. A matching engine sits at the core of every exchange, pairing buyers with sellers based on who offered the best price first.
In this project, you will build the backend system that powers this process. You will create a local crypto exchange that accepts limit orders, matches them using price-time priority, and streams every trade to connected clients in real-time.
What You'll Build
You will place a buy order for Bitcoin, watch it wait in the order book, then submit a matching sell order and see the engine execute the trade instantly while a WebSocket client receives the update live.
By the end of this project, you'll have:
- Submit a limit buy order at $50,000 for 0.5 BTC and watch it sit in the order book waiting for a matching seller.
- Submit a sell order at the same price and watch the matching engine pair the orders, execute the trade, and settle both wallets atomically.
- Connect a WebSocket client and see order book depth and trade executions stream in real-time without polling.
- Secret Mission: Add market orders that execute immediately at the best available price, sweeping through multiple price levels.
Are there any prerequisites?
This guide includes full setup instructions for everything you need. You will install PostgreSQL and configure Prisma ORM as part of the first step.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. This step is your moment to commit to the goal before any code hits the screen.
Set Up the Project
Your matching engine needs three foundations: a database to persist orders and trades, a TypeScript runtime for type-safe code, and an ORM to bridge between them.
In this step, you'll install PostgreSQL 18, scaffold a TypeScript project with Express, and wire up Prisma ORM so your application can talk to the database.
In this step, get ready to:
- Install PostgreSQL 18 and create the exchange database.
- Scaffold the TypeScript + Express project with Prisma.
- Create the server entry point and verify it starts.
Install PostgreSQL and create the exchange database
The matching engine stores every order, trade, and wallet balance in PostgreSQL. You need the database server running locally before you can connect anything.
- Check if PostgreSQL is already installed by running this command:
psql --version
✔️ I see version 18 or higher
PostgreSQL 18 is ready. Skip ahead to the database creation commands below.
ⓧ I see an older version
You have an older PostgreSQL installed. Install version 18 alongside it using the PGDG repository by running these commands:
sudo apt install -y postgresql-common ca-certificates
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt update && sudo apt install postgresql-18
What do these commands do?
- The first command installs the shared PostgreSQL utilities needed to configure the repository.
- The second command runs a script that adds the official PGDG apt repository to your system. It may ask for confirmation to proceed.
- The third command refreshes your package list and installs PostgreSQL 18.
After installation completes, verify by running:
psql --version
You should see psql (PostgreSQL) 18 followed by a patch number.
ⓧ Command not found
PostgreSQL is not installed yet. Install version 18 from the official PGDG repository by running these commands:
sudo apt install -y postgresql-common ca-certificates
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt update && sudo apt install postgresql-18
What do these commands do?
- The first command installs the shared PostgreSQL utilities needed to configure the repository.
- The second command runs a script that adds the official PGDG apt repository to your system. It may ask for confirmation to proceed.
- The third command refreshes your package list and installs PostgreSQL 18.
After installation completes, verify by running:
psql --version
You should see psql (PostgreSQL) 18 followed by a patch number.
- Start the PostgreSQL service and create the database and user by running these commands:
sudo systemctl start postgresql
sudo -u postgres psql -c "CREATE USER exchange_user WITH PASSWORD 'exchange_pass';"
sudo -u postgres psql -c "CREATE DATABASE exchange OWNER exchange_user;"
What do these commands do?
- The first command starts the PostgreSQL background service if it is not already running.
- The second command creates a new database user called exchange_user with the password exchange_pass.
- The third command creates a database called exchange owned by that user.
- Verify the connection works by running:
psql -U exchange_user -h localhost -d exchange -c "SELECT 1;"
When prompted for a password, type exchange_pass and press Enter. You should see a table with the value 1 confirming the connection is live.
Connection refused or authentication failed?
- If you see "connection refused", make sure PostgreSQL is running with sudo systemctl status postgresql. If it says inactive, start it again.
- If you see "authentication failed", double-check you typed the password exchange_pass correctly. You can drop and recreate the user if needed.
- help me debug my PostgreSQL connection
Scaffold the TypeScript + Express project with Prisma
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir exchange-engine
cd exchange-engine
- Initialize a new Node.js project by running:
npm init -y
You should see a JSON summary of the generated package.json printed to the terminal.
- Install the production dependencies by running:
npm install express ws dotenv @prisma/client @prisma/adapter-pg pg
- Install the development dependencies by running:
npm install --save-dev typescript tsx prisma @types/node @types/express @types/ws @types/pg
Both install commands produce output showing packages added. You now have all the libraries your exchange needs.
Seeing permission errors during install?
- Make sure you are inside the exchange-engine folder. Run pwd to confirm.
- If you see EACCES errors, do not use sudo with npm. Fix npm permissions instead.
- help me fix npm install errors
- Create tsconfig.json in the project root with this content:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2023",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*", "prisma/**/*"],
"exclude": ["node_modules", "dist"]
}
What does this configure?
- module: "ESNext" and moduleResolution: "bundler" enable modern ES module imports. Prisma 7 requires this ESM-first setup.
- target: "ES2023" compiles to recent JavaScript that Node.js supports natively.
- strict: true catches type errors at compile time instead of letting them slip to runtime.
- Initialize Prisma with the PostgreSQL provider by running:
npx prisma init --datasource-provider postgresql --output ../generated/prisma
You should see output confirming that prisma/schema.prisma and prisma.config.ts were created.
What did Prisma just create?
- prisma/schema.prisma is where you define your database models. The --output flag tells Prisma to generate the client into generated/prisma.
- prisma.config.ts tells Prisma where to find your database URL. It reads from the DATABASE_URL environment variable.
- A .env file was also created with a placeholder database URL you'll update next.
- Open the .env file in VS Code and replace its contents with:
DATABASE_URL="postgresql://exchange_user:exchange_pass@localhost:5432/exchange?schema=public"
What is this connection string?
This URL tells Prisma how to reach your database: the user (exchange_user), the password (exchange_pass), the host (localhost:5432), and the database name (exchange). These match what you created in the previous substep.
- Open package.json in VS Code. Find the "version" line and add "type": "module" directly below it.
- Replace the existing "scripts" section with the following:
"scripts": {
"dev": "npx tsx src/index.ts",
"seed": "npx tsx prisma/seed.ts",
"migrate": "npx prisma migrate dev",
"generate": "npx prisma generate"
}
- Save package.json.
- Confirm all configuration files exist by running:
ls
You should see node_modules, package.json, prisma, tsconfig.json, .env, and prisma.config.ts listed.
✔️ Awesome, I've got everything!
Great. Double check you have saved both tsconfig.json and package.json.
ⓧ I'd like to double check the full code
Here is what your tsconfig.json should look like:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2023",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*", "prisma/**/*"],
"exclude": ["node_modules", "dist"]
}
Here is what your package.json should look like. Dependency version numbers may be slightly higher than shown:
{
"name": "exchange-engine",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "npx tsx src/index.ts",
"seed": "npx tsx prisma/seed.ts",
"migrate": "npx prisma migrate dev",
"generate": "npx prisma generate"
},
"dependencies": {
"@prisma/adapter-pg": "^6.9.0",
"@prisma/client": "^6.9.0",
"dotenv": "^16.4.7",
"express": "^5.1.0",
"pg": "^8.13.3",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.0",
"@types/pg": "^8.11.0",
"@types/ws": "^8.5.0",
"prisma": "^6.9.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
Here is what your .env should look like:
DATABASE_URL="postgresql://exchange_user:exchange_pass@localhost:5432/exchange?schema=public"
Create the Express server entry point and verify it starts
- Create the src directory and the nested lib directory by running:
mkdir -p src/lib
- Create src/index.ts in VS Code with the following code:
import "dotenv/config";
import express from "express";
// Create the Express application
const app = express();
const PORT = 3000;
// Parse incoming JSON request bodies
app.use(express.json());
// Health check endpoint to confirm the server is alive
app.get("/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Start listening for requests
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
- import "dotenv/config" loads environment variables from your .env file into process.env automatically.
- express.json() is middleware that parses JSON bodies on incoming requests. Every order submission will arrive as JSON.
- The /health endpoint returns a simple JSON response so you can quickly confirm the server is responding.
- Save src/index.ts.
Before you run the server, what do you think you will see printed in the terminal?
- Start the server by running:
npx tsx src/index.ts
You should see Server running on http://localhost:3000 printed to the terminal. The process keeps running because the server is listening for incoming requests.
- Open a second terminal window. Test the health endpoint by running:
curl http://localhost:3000/health
You should see a JSON response like {"status":"ok","timestamp":"..."} confirming Express is serving requests.
- Return to your first terminal and press Ctrl+C to stop the server.
Server not starting or port already in use?
- If you see a module resolution error, confirm your package.json has "type": "module". Without it, Node.js treats .ts imports as CommonJS.
- If port 3000 is already in use, close other servers or find the process with lsof -i :3000 and kill it.
- help me debug why my Express server won't start
- Now create src/lib/prisma.ts in VS Code with the following code:
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../../generated/prisma/client.js";
// Read the database connection string from environment variables
const connectionString = process.env.DATABASE_URL!;
// Create the PostgreSQL adapter and Prisma client
const adapter = new PrismaPg({ connectionString });
const prisma = new PrismaClient({ adapter });
export { prisma };
What does this code do?
- PrismaPg is a driver adapter that wraps the pg library (node-postgres) for Prisma 7. This is how Prisma connects to PostgreSQL in its modern architecture.
- The ! after process.env.DATABASE_URL tells TypeScript the value is guaranteed to exist. It will read from the .env file you configured.
- Every route handler in the project will import prisma from this file to query the database.
- Save src/lib/prisma.ts.
- Confirm both source files exist by running:
ls src src/lib
You should see index.ts under src and prisma.ts under src/lib.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved in VS Code.
ⓧ I'd like to double check the full code
Here is what your src/index.ts should look like:
import "dotenv/config";
import express from "express";
const app = express();
const PORT = 3000;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Here is what your src/lib/prisma.ts should look like:
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../../generated/prisma/client.js";
const connectionString = process.env.DATABASE_URL!;
const adapter = new PrismaPg({ connectionString });
const prisma = new PrismaClient({ adapter });
export { prisma };
Your project skeleton is wired up and responding to requests. Next up, you'll define the database models that represent trading pairs, users, wallets, and orders.
Model the Exchange and Place Orders
Your Express server starts and the health endpoint responds. But an exchange needs more than a running process.
Before you can match orders, you need a data model that represents trading pairs, user wallets, and the orders themselves. You also need an API that accepts order submissions into the system. In this step, you'll define the full database schema, seed it with test data, and build the endpoints that let users place orders and view the order book.
In this step, get ready to:
- Define the Prisma schema with enums and models for trading pairs, users, wallets, orders, and trades.
- Build the POST /api/orders endpoint that validates input, checks balances, and locks funds.
- Build the GET /api/orderbook/:symbol and GET /api/trades/:symbol endpoints for market data.
Define the Prisma schema and run the migration
An exchange tracks several entities that relate to each other. Trading pairs define what can be traded. Users own wallets with balances. Orders represent intentions to buy or sell. Trades record what actually executed.
- Open prisma/schema.prisma in VS Code. Replace its contents with the following configuration and enum definitions:
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum Side {
BUY
SELL
}
enum OrderType {
LIMIT
MARKET
}
enum OrderStatus {
OPEN
PARTIAL
FILLED
CANCELLED
}
What do these enums represent?
Side defines whether an order is buying or selling. OrderType distinguishes limit orders (with a specific price) from market orders (fill at any price). OrderStatus tracks the lifecycle of an order from open through partial fills to fully filled or cancelled.
- Below the enums, add the first three models. These define what can be traded, who is trading, and what balances they hold:
model TradingPair {
id String @id @default(uuid())
baseAsset String
quoteAsset String
symbol String @unique
orders Order[]
trades Trade[]
}
model User {
id String @id @default(uuid())
email String @unique
wallets Wallet[]
orders Order[]
createdAt DateTime @default(now())
}
model Wallet {
id String @id @default(uuid())
userId String
asset String
available Float @default(0)
locked Float @default(0)
user User @relation(fields: [userId], references: [id])
@@unique([userId, asset])
}
Why separate available and locked balances?
When a user places an order, funds move from available to locked. This prevents double-spending. The funds stay locked until the order fills (transferred to counterparty) or is cancelled (returned to available).
- Below the Wallet model, add the Order and Trade models to complete the schema:
model Order {
id String @id @default(uuid())
userId String
pairId String
side Side
type OrderType @default(LIMIT)
price Float?
quantity Float
filled Float @default(0)
status OrderStatus @default(OPEN)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
pair TradingPair @relation(fields: [pairId], references: [id])
buys Trade[] @relation("BuyOrder")
sells Trade[] @relation("SellOrder")
}
model Trade {
id String @id @default(uuid())
pairId String
buyOrderId String
sellOrderId String
price Float
quantity Float
createdAt DateTime @default(now())
pair TradingPair @relation(fields: [pairId], references: [id])
buyOrder Order @relation("BuyOrder", fields: [buyOrderId], references: [id])
sellOrder Order @relation("SellOrder", fields: [sellOrderId], references: [id])
}
What does the Trade model record?
Each Trade links exactly one buy order and one sell order that matched. It records the execution price and quantity. The named relations ("BuyOrder" and "SellOrder") let Prisma distinguish which side of the trade each order represents.
- Save prisma/schema.prisma.
- Run the migration to create all the tables in your PostgreSQL database:
npx prisma migrate dev --name init
You should see output confirming the migration was applied and a new folder created under prisma/migrations/.
- In Prisma ORM 7, the client is no longer auto-generated after migration. Run the generate command separately:
npx prisma generate
You should see a message confirming the Prisma Client was generated into the generated/prisma directory.
Seeing a database connection error?
- Confirm PostgreSQL is running with sudo systemctl status postgresql.
- Check that your .env file has the correct DATABASE_URL with the username exchange_user, password exchange_pass, and database name exchange.
- help me fix my Prisma migration connection error
Now seed the database with test data so you have a trading pair and users to work with.
- Create a new file prisma/seed.ts and add the imports, client setup, and trading pair creation:
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client.js";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
const prisma = new PrismaClient({ adapter });
async function main() {
// Create the BTC-USD trading pair
const pair = await prisma.tradingPair.upsert({
where: { symbol: "BTC-USD" },
update: {},
create: {
baseAsset: "BTC",
quoteAsset: "USD",
symbol: "BTC-USD",
},
});
// Create first test user with wallets
const user1 = await prisma.user.upsert({
where: { email: "alice@exchange.local" },
update: {},
create: {
email: "alice@exchange.local",
wallets: {
create: [
{ asset: "USD", available: 100000, locked: 0 },
{ asset: "BTC", available: 2, locked: 0 },
],
},
},
});
- Below the user1 block, add the second test user and the execution code to complete the file:
// Create second test user with wallets
const user2 = await prisma.user.upsert({
where: { email: "bob@exchange.local" },
update: {},
create: {
email: "bob@exchange.local",
wallets: {
create: [
{ asset: "USD", available: 100000, locked: 0 },
{ asset: "BTC", available: 2, locked: 0 },
],
},
},
});
console.log("Seeded:", { pair, user1, user2 });
}
main()
.then(() => prisma.$disconnect())
.catch((e) => {
console.error(e);
prisma.$disconnect();
process.exit(1);
});
Why use upsert instead of create?
The upsert operation creates the record if it doesn't exist, or does nothing if it already does. This makes the seed script safe to run multiple times without duplicate-key errors.
- Save prisma/seed.ts.
- Run the seed script:
npx tsx prisma/seed.ts
You should see output showing the created pair, user1, and user2 objects with their generated UUIDs. Note the id value for one of the users. You will need it shortly to test the order endpoint.
Seeing a module not found error?
- Make sure you ran npx prisma generate after the migration. The seed script imports from ../generated/prisma/client.js which only exists after generation.
- help me fix my Prisma seed script import error
Build the POST /api/orders endpoint
With the data model in place, you need an API endpoint that accepts order submissions. The endpoint must validate the request, confirm the user has enough funds, lock those funds so they cannot be double-spent, and create the order in the database.
The route files will import a matching engine module that you will build fully in the next step. For now, you will create a lightweight placeholder so the imports resolve.
- Create the directory src/engine/ and a placeholder file src/engine/matcher.ts with this stub:
// Placeholder - full matching engine built in Step 3
class MatchingEngine {
getDepth(symbol: string) {
return { bids: [] as { price: number; quantity: number }[], asks: [] as { price: number; quantity: number }[] };
}
}
export const engine = new MatchingEngine();
Why create this stub now?
The route files you are about to create import from ../engine/matcher.js. Without a file at that path, the server would crash on startup. This stub provides the minimum interface (getDepth) needed for the market endpoint. You will replace it with the real engine in the next step.
- Create src/routes/orders.ts and add the imports, router setup, and request validation:
import { Router, Request, Response } from "express";
import { prisma } from "../lib/prisma.js";
import { engine } from "../engine/matcher.js";
const router = Router();
router.post("/", async (req: Request, res: Response) => {
const { userId, pair, side, price, quantity } = req.body;
// Validate required fields
if (!userId || !pair || !side || !price || !quantity) {
res.status(400).json({ error: "Missing required fields: userId, pair, side, price, quantity" });
return;
}
if (side !== "BUY" && side !== "SELL") {
res.status(400).json({ error: "side must be BUY or SELL" });
return;
}
if (price <= 0 || quantity <= 0) {
res.status(400).json({ error: "price and quantity must be positive" });
return;
}
// Look up the trading pair
const tradingPair = await prisma.tradingPair.findUnique({ where: { symbol: pair } });
if (!tradingPair) {
res.status(404).json({ error: `Trading pair ${pair} not found` });
return;
}
What does this validation do?
The handler rejects requests missing any required field, orders with an invalid side, non-positive prices or quantities, and unknown trading pair symbols. Each returns an appropriate HTTP status code so the client knows exactly what went wrong.
- Below the trading pair lookup, add the wallet balance check and fund locking logic:
// Determine which asset to check based on order side
const [baseAsset, quoteAsset] = pair.split("-");
if (side === "BUY") {
// Buyer needs enough quote asset (USD) to cover price * quantity
const quoteNeeded = price * quantity;
const wallet = await prisma.wallet.findUnique({
where: { userId_asset: { userId, asset: quoteAsset } },
});
if (!wallet || wallet.available < quoteNeeded) {
res.status(400).json({ error: `Insufficient ${quoteAsset} balance` });
return;
}
// Lock the funds so they can't be double-spent
await prisma.wallet.update({
where: { userId_asset: { userId, asset: quoteAsset } },
data: { available: { decrement: quoteNeeded }, locked: { increment: quoteNeeded } },
});
} else {
// Seller needs enough base asset (BTC) to cover quantity
const wallet = await prisma.wallet.findUnique({
where: { userId_asset: { userId, asset: baseAsset } },
});
if (!wallet || wallet.available < quantity) {
res.status(400).json({ error: `Insufficient ${baseAsset} balance` });
return;
}
await prisma.wallet.update({
where: { userId_asset: { userId, asset: baseAsset } },
data: { available: { decrement: quantity }, locked: { increment: quantity } },
});
}
How does fund locking work?
A buyer placing a BUY order at $50,000 for 0.5 BTC needs $25,000 locked. The decrement on available and increment on locked are atomic Prisma operations. If a user tries to place two orders that together exceed their balance, the second will be rejected because the first already moved funds out of available.
- Below the wallet logic, add the order creation and response to complete the file:
// Create the order in the database
const order = await prisma.order.create({
data: {
userId,
pairId: tradingPair.id,
side,
type: "LIMIT",
price,
quantity,
filled: 0,
status: "OPEN",
},
});
const updatedOrder = await prisma.order.findUnique({ where: { id: order.id } });
res.status(201).json({ order: updatedOrder, trades: [] });
});
export default router;
Why return trades as an empty array?
In the next step, you will build the matching engine that checks if an incoming order can trade against existing orders. For now, the order simply rests in the book with status OPEN. The trades: [] field keeps the response shape consistent for when matching is added.
- Save src/routes/orders.ts.
- Now update src/index.ts to mount the orders router. Find these lines at the top of the file:
import "dotenv/config";
import express from "express";
- Add the router imports directly below them, so the top of the file looks like this:
import "dotenv/config";
import express from "express";
import ordersRouter from "./routes/orders.js";
import marketRouter from "./routes/market.js";
- Find the app.listen call at the bottom of src/index.ts. Add these two lines directly above it:
app.use("/api/orders", ordersRouter);
app.use("/api", marketRouter);
- Save src/index.ts. You will create the market router file next before starting the server.
Build the GET /api/orderbook/:symbol endpoint
Clients need to see the current state of the order book and recent trades. These read-only endpoints query the engine and database to return live market data.
- Create src/routes/market.ts with both the order book depth and trades endpoints:
import { Router, Request, Response } from "express";
import { prisma } from "../lib/prisma.js";
import { engine } from "../engine/matcher.js";
const router = Router();
// Return current order book depth for a symbol
router.get("/orderbook/:symbol", (req: Request, res: Response) => {
const { symbol } = req.params;
const depth = engine.getDepth(symbol);
res.json(depth);
});
// Return recent trades for a symbol
router.get("/trades/:symbol", async (req: Request, res: Response) => {
const { symbol } = req.params;
const pair = await prisma.tradingPair.findUnique({ where: { symbol } });
if (!pair) {
res.status(404).json({ error: `Trading pair ${symbol} not found` });
return;
}
const trades = await prisma.trade.findMany({
where: { pairId: pair.id },
orderBy: { createdAt: "desc" },
take: 50,
});
res.json(trades);
});
export default router;
What do these endpoints return?
The /orderbook/:symbol endpoint returns the current bid and ask depth from the in-memory engine. Right now the stub returns empty arrays. Once the matching engine is built, this will show live order book state.
The /trades/:symbol endpoint queries the database for the 50 most recent trades, sorted newest first.
- Save src/routes/market.ts.
- Start the server to confirm everything compiles and the routes are mounted:
npx tsx src/index.ts
You should see Server running on http://localhost:3000 printed in the terminal.
Seeing an import error for matcher.js?
- Confirm you created src/engine/matcher.ts with the stub code. The engine directory must exist inside src/.
- Check the import path in your route files uses ../engine/matcher.js (with the .js extension, required for ESM).
- help me fix my engine matcher import error
Before you test the order endpoint, think about what you expect to happen. You will send a BUY order for a user with $100,000 available. What HTTP status code should come back, and what should the order status be?
- Open a second terminal. Test the POST /api/orders endpoint by placing a buy order. Replace the userId value with the UUID you noted from the seed output:
curl -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"userId":"[[USER_ID=\"your user1 UUID from seed output\"]]","pair":"BTC-USD","side":"BUY","price":50000,"quantity":0.5}'
You should see a JSON response containing the order object with status: "OPEN", the assigned id, and trades: []. The order was accepted, funds were locked, and it now rests in the database waiting for a matching sell order.
- Verify the order book endpoint responds. Run:
curl http://localhost:3000/api/orderbook/BTC-USD
You should see {"bids":[],"asks":[]}. The order book is empty because the stub engine has no knowledge of orders yet. The full matching engine in the next step will populate this.
Getting a 400 or 404 error on the order request?
- If you see Trading pair BTC-USD not found, confirm you ran the seed script successfully.
- If you see Insufficient USD balance, double-check the userId matches an actual UUID from the seed output. A mistyped UUID creates a lookup against a non-existent user.
- If you see Missing required fields, check your JSON includes all five fields: userId, pair, side, price, quantity.
- help me debug my order submission error
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files, then move on.
ⓧ I'd like to double check the full code
Your project now has several files. Scroll up and confirm each one is present and saved:
- prisma/schema.prisma with all 3 enums and 5 models (TradingPair, User, Wallet, Order, Trade).
- prisma/seed.ts with BTC-USD pair, alice, and bob.
- src/engine/matcher.ts with the placeholder MatchingEngine class exporting engine.
- src/routes/orders.ts with the POST handler (validation, wallet check, fund lock, order create).
- src/routes/market.ts with GET /orderbook/:symbol and GET /trades/:symbol.
- src/index.ts importing and mounting both ordersRouter and marketRouter.
You now have a working API that accepts orders, validates balances, and locks funds. The order sits in the database with status OPEN, but nothing matches it yet. Next up, you will build the matching engine that pairs compatible buy and sell orders and executes trades.
Build the Matching Engine
Your exchange can accept orders and store them in the database. But right now those orders just sit there doing nothing.
You need a matching engine that compares incoming orders against the opposite side of the book, pairs compatible ones using price-time priority, and executes trades atomically. Without this, your exchange is just a list of intentions.
In this step, get ready to:
- Build the in-memory order book data structure with sorted bids and asks.
- Build the matching engine that pairs compatible orders and executes trades.
- Integrate the engine into the order submission flow and verify trades execute.
Build the in-memory order book data structure
The order book holds all open orders in memory, sorted so the engine can instantly find the best available price on each side.
What is price-time priority?
Bids (buy orders) are sorted highest price first. If two bids share the same price, the one placed earlier gets filled first.
Asks (sell orders) are sorted lowest price first. Same tie-breaking applies. This is how every centralized exchange determines fill priority.
- Create the file src/engine/orderbook.ts and add the BookOrder interface plus the start of the OrderBook class with the addOrder method:
export interface BookOrder {
id: string;
userId: string;
side: "BUY" | "SELL";
price: number;
quantity: number;
filled: number;
createdAt: Date;
}
export class OrderBook {
private bids: BookOrder[] = [];
private asks: BookOrder[] = [];
addOrder(order: BookOrder): void {
if (order.side === "BUY") {
this.bids.push(order);
// Sort bids: highest price first, earliest time first
this.bids.sort((a, b) => {
if (b.price !== a.price) return b.price - a.price;
return a.createdAt.getTime() - b.createdAt.getTime();
});
} else {
this.asks.push(order);
// Sort asks: lowest price first, earliest time first
this.asks.sort((a, b) => {
if (a.price !== b.price) return a.price - b.price;
return a.createdAt.getTime() - b.createdAt.getTime();
});
}
}
What does this code do?
The BookOrder interface defines what an order looks like in memory: its ID, which side it's on, its price and quantity, how much has been filled so far, and when it was placed.
The addOrder method inserts an order into the correct side and re-sorts. Bids go descending by price (best bid on top). Asks go ascending (cheapest ask on top). Ties break by time.
- Add the accessor methods below the addOrder method:
removeOrder(orderId: string): void {
this.bids = this.bids.filter((o) => o.id !== orderId);
this.asks = this.asks.filter((o) => o.id !== orderId);
}
getBestBid(): BookOrder | undefined {
return this.bids[0];
}
getBestAsk(): BookOrder | undefined {
return this.asks[0];
}
getBids(): BookOrder[] {
return this.bids;
}
getAsks(): BookOrder[] {
return this.asks;
}
What does this code do?
These are simple lookups. getBestBid() returns the highest-priced buy order (index 0 after sorting). getBestAsk() returns the cheapest sell order. The matching engine uses these to decide if a trade can execute.
- Add the getDepth method and close the class:
getDepth(): { bids: { price: number; quantity: number }[]; asks: { price: number; quantity: number }[] } {
// Aggregate remaining quantities at each price level
const bidDepth = new Map<number, number>();
for (const bid of this.bids) {
const remaining = bid.quantity - bid.filled;
bidDepth.set(bid.price, (bidDepth.get(bid.price) || 0) + remaining);
}
const askDepth = new Map<number, number>();
for (const ask of this.asks) {
const remaining = ask.quantity - ask.filled;
askDepth.set(ask.price, (askDepth.get(ask.price) || 0) + remaining);
}
return {
bids: Array.from(bidDepth.entries()).map(([price, quantity]) => ({ price, quantity })),
asks: Array.from(askDepth.entries()).map(([price, quantity]) => ({ price, quantity })),
};
}
}
What does getDepth do?
The getDepth method groups all open orders by price level and sums up remaining quantities. This is the "order book depth" you see on exchange UIs showing total volume available at each price.
- Save src/engine/orderbook.ts.
The order book data structure is ready. Next you will build the engine that uses it to match orders.
Build the matching engine that executes trades
The matching engine is the core of your exchange. When a new order arrives, it checks the opposite side of the book for compatible prices, executes trades, and updates wallets atomically.
- Create src/engine/matcher.ts and add the imports, interface, and class setup:
import { EventEmitter } from "events";
import { OrderBook, BookOrder } from "./orderbook.js";
import { prisma } from "../lib/prisma.js";
// Shape of a completed trade returned to the caller
export interface TradeResult {
id: string;
buyOrderId: string;
sellOrderId: string;
price: number;
quantity: number;
createdAt: Date;
}
class MatchingEngine extends EventEmitter {
// One order book per trading pair
private books: Map<string, OrderBook> = new Map();
getOrCreateBook(symbol: string): OrderBook {
if (!this.books.has(symbol)) {
this.books.set(symbol, new OrderBook());
}
return this.books.get(symbol)!;
}
Why extend EventEmitter?
The engine emits events when trades execute or the order book changes. In the next step, you will connect a WebSocket server that listens to these events and broadcasts updates to connected clients in real time.
- Add the start of the processOrder method with the BUY matching loop:
async processOrder(order: BookOrder, pairId: string, symbol: string): Promise<TradeResult[]> {
const book = this.getOrCreateBook(symbol);
const trades: TradeResult[] = [];
if (order.side === "BUY") {
let remaining = order.quantity - order.filled;
while (remaining > 0) {
const bestAsk = book.getBestAsk();
// A BUY matches if its price >= the best ask price
if (!bestAsk || bestAsk.price > order.price) break;
const askRemaining = bestAsk.quantity - bestAsk.filled;
const fillQty = Math.min(remaining, askRemaining);
// Execute at the resting order's price
const fillPrice = bestAsk.price;
bestAsk.filled += fillQty;
order.filled += fillQty;
remaining -= fillQty;
What does this code do?
The while loop keeps matching the incoming buy order against the cheapest available sell orders. It stops when there are no more compatible asks (the ask price is too high) or the buy order is fully filled.
The trade executes at the resting order's price (the ask that was already sitting in the book), not the incoming order's price. This is the "maker price" convention used on real exchanges.
- Continue the BUY loop by adding the trade record creation and status updates:
const trade = await prisma.trade.create({
data: {
pairId,
buyOrderId: order.id,
sellOrderId: bestAsk.id,
price: fillPrice,
quantity: fillQty,
},
});
trades.push({
id: trade.id,
buyOrderId: order.id,
sellOrderId: bestAsk.id,
price: fillPrice,
quantity: fillQty,
createdAt: trade.createdAt,
});
const newAskStatus = bestAsk.filled >= bestAsk.quantity ? "FILLED" : "PARTIAL";
await prisma.order.update({
where: { id: bestAsk.id },
data: { filled: bestAsk.filled, status: newAskStatus },
});
await this.settleWallets(order.userId, bestAsk.userId, "BUY", fillPrice, fillQty, symbol);
if (bestAsk.filled >= bestAsk.quantity) {
book.removeOrder(bestAsk.id);
}
}
What does this code do?
For each fill, a Trade record is persisted to the database, the matched ask order's status is updated (to PARTIAL or FILLED), wallets are settled, and fully-filled orders are removed from the in-memory book.
- Add the BUY branch wrap-up that updates the incoming order's status and rests any unfilled portion:
// Update the incoming buy order's status
const newStatus = order.filled >= order.quantity ? "FILLED" : order.filled > 0 ? "PARTIAL" : "OPEN";
await prisma.order.update({
where: { id: order.id },
data: { filled: order.filled, status: newStatus },
});
// If not fully filled, rest the remainder in the book
if (order.filled < order.quantity) {
book.addOrder(order);
}
} else {
What does this code do?
After matching completes, the buy order is marked FILLED (everything matched), PARTIAL (some matched), or stays OPEN (nothing matched). If any quantity remains, the order rests in the book waiting for a future sell to match against it.
- Add the SELL matching loop. It mirrors the BUY logic but matches against bids instead:
let remaining = order.quantity - order.filled;
while (remaining > 0) {
const bestBid = book.getBestBid();
// A SELL matches if its price <= the best bid price
if (!bestBid || bestBid.price < order.price) break;
const bidRemaining = bestBid.quantity - bestBid.filled;
const fillQty = Math.min(remaining, bidRemaining);
const fillPrice = bestBid.price;
bestBid.filled += fillQty;
order.filled += fillQty;
remaining -= fillQty;
const trade = await prisma.trade.create({
data: {
pairId,
buyOrderId: bestBid.id,
sellOrderId: order.id,
price: fillPrice,
quantity: fillQty,
},
});
- Continue the SELL loop with the trade tracking and status updates:
trades.push({
id: trade.id,
buyOrderId: bestBid.id,
sellOrderId: order.id,
price: fillPrice,
quantity: fillQty,
createdAt: trade.createdAt,
});
const newBidStatus = bestBid.filled >= bestBid.quantity ? "FILLED" : "PARTIAL";
await prisma.order.update({
where: { id: bestBid.id },
data: { filled: bestBid.filled, status: newBidStatus },
});
await this.settleWallets(bestBid.userId, order.userId, "SELL", fillPrice, fillQty, symbol);
if (bestBid.filled >= bestBid.quantity) {
book.removeOrder(bestBid.id);
}
}
- Close the SELL branch, emit events, and close the processOrder method:
const newStatus = order.filled >= order.quantity ? "FILLED" : order.filled > 0 ? "PARTIAL" : "OPEN";
await prisma.order.update({
where: { id: order.id },
data: { filled: order.filled, status: newStatus },
});
if (order.filled < order.quantity) {
book.addOrder(order);
}
}
// Emit events for WebSocket broadcasting
if (trades.length > 0) {
this.emit("trades", { symbol, trades });
}
this.emit("orderbook", { symbol, depth: book.getDepth() });
return trades;
}
What do the events do?
Every time the engine processes an order, it emits an orderbook event with the updated depth. If trades were executed, it also emits a trades event. You will wire these to WebSocket clients in Step 4.
- Add the settleWallets method that transfers funds between buyer and seller:
private async settleWallets(
buyerUserId: string,
sellerUserId: string,
incomingSide: "BUY" | "SELL",
price: number,
quantity: number,
symbol: string
): Promise<void> {
const [baseAsset, quoteAsset] = symbol.split("-");
const quoteAmount = price * quantity;
// Buyer: locked USD decreases, available BTC increases
await prisma.wallet.update({
where: { userId_asset: { userId: buyerUserId, asset: quoteAsset } },
data: { locked: { decrement: quoteAmount } },
});
await prisma.wallet.update({
where: { userId_asset: { userId: buyerUserId, asset: baseAsset } },
data: { available: { increment: quantity } },
});
// Seller: locked BTC decreases, available USD increases
await prisma.wallet.update({
where: { userId_asset: { userId: sellerUserId, asset: baseAsset } },
data: { locked: { decrement: quantity } },
});
await prisma.wallet.update({
where: { userId_asset: { userId: sellerUserId, asset: quoteAsset } },
data: { available: { increment: quoteAmount } },
});
}
What does settleWallets do?
When a trade executes, the buyer's locked quote asset (USD) is released and the seller's base asset (BTC) transfers to the buyer. The seller's locked BTC is released and the buyer's USD transfers to the seller. This is the atomic settlement that prevents double-spending.
- Add the getDepth method, the loadFromDatabase method, and export the singleton:
getDepth(symbol: string): { bids: { price: number; quantity: number }[]; asks: { price: number; quantity: number }[] } {
const book = this.getOrCreateBook(symbol);
return book.getDepth();
}
async loadFromDatabase(): Promise<void> {
// Rebuild the in-memory book from persisted open orders
const openOrders = await prisma.order.findMany({
where: { status: { in: ["OPEN", "PARTIAL"] } },
include: { pair: true },
orderBy: { createdAt: "asc" },
});
for (const order of openOrders) {
const book = this.getOrCreateBook(order.pair.symbol);
book.addOrder({
id: order.id,
userId: order.userId,
side: order.side as "BUY" | "SELL",
price: order.price!,
quantity: order.quantity,
filled: order.filled,
createdAt: order.createdAt,
});
}
console.log(`Loaded ${openOrders.length} open orders into memory`);
}
}
export const engine = new MatchingEngine();
Why load from the database on startup?
The in-memory order book is lost when the server restarts. The loadFromDatabase method rebuilds it from all OPEN and PARTIAL orders stored in PostgreSQL. This means your exchange recovers its full state after any restart.
- Save src/engine/matcher.ts.
- Restart the server by pressing Ctrl+C in the terminal and running:
npx tsx src/index.ts
- You should see Loaded 0 open orders into memory followed by Server running on http://localhost:3000. The engine files are resolving correctly.
Seeing a module not found error?
Make sure both src/engine/orderbook.ts and src/engine/matcher.ts exist. The routes already import from ../engine/matcher.js so both files must be present for the server to start.
help me debug a module not found error in my matching engine imports
Integrate the engine into the order submission flow
The engine exists but your orders endpoint still returns empty trades. You need to call the engine after creating an order, and load the book from the database on startup.
- In src/routes/orders.ts, find these lines near the bottom of the POST handler:
const updatedOrder = await prisma.order.findUnique({ where: { id: order.id } });
res.status(201).json({ order: updatedOrder, trades: [] });
- Replace those lines with this code that passes the order to the matching engine:
// Pass the new order to the matching engine
const trades = await engine.processOrder(
{
id: order.id,
userId: order.userId,
side: order.side as "BUY" | "SELL",
price: order.price!,
quantity: order.quantity,
filled: order.filled,
createdAt: order.createdAt,
},
tradingPair.id,
pair
);
const updatedOrder = await prisma.order.findUnique({ where: { id: order.id } });
res.status(201).json({ order: updatedOrder, trades });
What does this change do?
Instead of returning an empty trades array, the endpoint now passes the newly created order to the engine for matching. The engine attempts to fill it against the opposite side of the book. The response includes any trades that executed and the order's updated status (which might now be FILLED or PARTIAL).
- Save src/routes/orders.ts.
- Now open src/index.ts and find the bottom of the file:
app.use("/api/orders", ordersRouter);
app.use("/api", marketRouter);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
- Replace the app.listen(...) block (the last 3 lines) with an async start function that loads the order book first:
async function start() {
// Rebuild the in-memory order book from the database
await engine.loadFromDatabase();
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
}
start().catch(console.error);
- Add the engine import at the top of src/index.ts. Find the line:
import marketRouter from "./routes/market.js";
- Add this line directly below it:
import { engine } from "./engine/matcher.js";
- Save src/index.ts.
Before you run the server, do you think placing a buy and then a matching sell will result in a trade? What would prove the engine is working?
- Restart the server:
npx tsx src/index.ts
- You should see Loaded 0 open orders into memory and Server running on http://localhost:3000.
- Open a second terminal. First, you need Alice's user ID. Run the seed script to get it:
npx tsx prisma/seed.ts
- Note the id values for user1 (Alice) and user2 (Bob) from the output.
- Place a BUY order for Alice at $50,000 for 0.5 BTC by running this command (replace the userId with Alice's ID from the seed output):
curl -X POST http://localhost:3000/api/orders -H "Content-Type: application/json" -d '{"userId":"[[ALICE_ID="Alice user ID from seed output"]]","pair":"BTC-USD","side":"BUY","price":50000,"quantity":0.5}'
- The response should show the order with "status":"OPEN" and "trades":[]. No sell orders exist yet, so nothing matches.
- Now place a SELL order for Bob at $50,000 for 0.3 BTC:
curl -X POST http://localhost:3000/api/orders -H "Content-Type: application/json" -d '{"userId":"[[BOB_ID="Bob user ID from seed output"]]","pair":"BTC-USD","side":"SELL","price":50000,"quantity":0.3}'
- This time the response should include a trades array with one trade for 0.3 BTC at price 50000. The sell order should show "status":"FILLED".
- Verify the order book shows Alice's remaining 0.2 BTC:
curl http://localhost:3000/api/orderbook/BTC-USD
- You should see a bid at price 50000 with quantity 0.2. That is Alice's partially-filled buy order still resting in the book.
- Confirm the trade was persisted:
curl http://localhost:3000/api/trades/BTC-USD
- You should see one trade object with "price":50000 and "quantity":0.3. The matching engine is working.
Seeing an empty trades array on the sell order?
Make sure you replaced the correct lines in src/routes/orders.ts. The engine.processOrder(...) call must happen after the prisma.order.create call. Also verify that the buy order was placed first (the book needs a resting order for the sell to match against).
help me debug why my matching engine is not executing trades
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm each chunk is present in order. Your files should contain these sections:
- src/engine/orderbook.ts: BookOrder interface, OrderBook class with addOrder, removeOrder, getBestBid, getBestAsk, getBids, getAsks, getDepth.
- src/engine/matcher.ts: TradeResult interface, MatchingEngine class with getOrCreateBook, processOrder (BUY loop + SELL loop + event emission), settleWallets, getDepth, loadFromDatabase. Exported as singleton engine.
- src/routes/orders.ts: POST handler calls engine.processOrder(...) after creating the order and returns the trades array.
- src/index.ts: imports engine, wraps startup in async start() that calls engine.loadFromDatabase() before listening.
Your matching engine is live. Orders match, trades execute, and wallets settle atomically. Next up, you will add WebSocket streaming so connected clients see these trades the instant they happen.
Stream Live Updates via WebSocket
Your matching engine executes trades and updates the order book, but right now the only way to see those changes is by polling the REST API repeatedly. That is wasteful and slow.
Real exchanges push updates to clients the instant something happens. WebSocket gives you a persistent, bidirectional connection where the server can send data to the client at any time without the client asking. In this step, you will create a WebSocket server that broadcasts live order book depth and trade executions to every connected client.
In this step, get ready to:
- Create a WebSocket server that sends the current order book snapshot on connection.
- Broadcast real-time order book and trade events to all connected clients.
- Test the full live-update flow end-to-end with wscat and curl.
Create the WebSocket server alongside Express
The WebSocket server runs on its own port (3001) separately from the Express HTTP server (3000). When a client connects, it immediately receives the current order book state so it can render without making a separate REST call.
- Create the file src/ws/server.ts by running:
mkdir -p src/ws && touch src/ws/server.ts
- Add the connection handler to src/ws/server.ts by pasting:
import { WebSocketServer, WebSocket } from "ws";
import { engine } from "../engine/matcher.js";
export function startWebSocketServer(port: number): WebSocketServer {
const wss = new WebSocketServer({ port });
// Track all connected WebSocket clients
const clients = new Set<WebSocket>();
// Send initial order book snapshot when a client connects
wss.on("connection", (ws: WebSocket) => {
clients.add(ws);
const snapshot = engine.getDepth("BTC-USD");
ws.send(JSON.stringify({ type: "orderbook", data: snapshot }));
ws.on("close", () => {
clients.delete(ws);
});
});
console.log(`WebSocket server running on ws://localhost:${port}`);
return wss;
}
What does this code do?
- WebSocketServer({ port }) creates a standalone WebSocket server on port 3001, separate from Express.
- clients is a Set that tracks every connected WebSocket so you can broadcast to all of them later.
- On each new connection, engine.getDepth("BTC-USD") grabs the current order book and sends it as the first message. The client has the full picture without a separate REST request.
- When a client disconnects, the close handler removes it from the Set so you never broadcast to a dead socket.
Now wire the WebSocket server into your application entry point.
- In src/index.ts, find this import line:
import { engine } from "./engine/matcher.js";
- Add this import directly below it:
import { startWebSocketServer } from "./ws/server.js";
- Find this line:
const PORT = 3000;
- Add this line directly below it:
const WS_PORT = 3001;
- Inside the start() function, find this line:
await engine.loadFromDatabase();
- Add this call directly below it:
startWebSocketServer(WS_PORT);
- Save both src/ws/server.ts and src/index.ts.
- Stop your running server with Ctrl+C if it is still running, then restart it by running:
npx tsx src/index.ts
You should see both startup messages printed:
WebSocket server running on ws://localhost:3001
Server running on http://localhost:3000
Now verify the WebSocket connection works. Open a second terminal and connect to the server by running:
npx wscat -c ws://localhost:3001
What do you expect to see the moment you connect?
You should see a JSON message with the current order book snapshot:
< {"type":"orderbook","data":{"bids":[],"asks":[]}}
The bids and asks are empty because no orders are currently resting in the book. Keep this wscat terminal open for the next substep.
Not seeing the snapshot message?
- Make sure the server is running in your first terminal and you see the WebSocket server running on ws://localhost:3001 message.
- Check that you typed the URL exactly as ws://localhost:3001 with no trailing slash.
- If you see a connection error, confirm no other process is using port 3001.
Why isn't my WebSocket connection working?
Broadcast order book and trade events
The matching engine already emits events when trades execute or the order book changes. You need a broadcast function that pushes those events to every connected client.
- In src/ws/server.ts, find this line near the bottom:
console.log(`WebSocket server running on ws://localhost:${port}`);
- Add the following code directly above that line:
// Send a message to every connected client
function broadcast(message: string): void {
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
}
// Push updated order book depth whenever it changes
engine.on("orderbook", (data: { symbol: string; depth: object }) => {
broadcast(JSON.stringify({ type: "orderbook", data: data.depth }));
});
// Push trade executions to all clients in real time
engine.on("trades", (data: { symbol: string; trades: object[] }) => {
broadcast(JSON.stringify({ type: "trades", data: data.trades }));
});
What does this code do?
- broadcast() loops through the clients Set and sends a message to each one that is still connected (readyState === WebSocket.OPEN).
- engine.on("orderbook", ...) fires every time a new order is placed or filled, pushing the updated depth to all clients.
- engine.on("trades", ...) fires when the matching engine executes a trade, so clients see fills the instant they happen.
Why WebSocket instead of polling?
With REST polling, each client would hit the server every second (or faster) asking "anything new?" and usually getting the same stale data back. That wastes bandwidth and CPU on both sides.
WebSocket flips the model. The server pushes only when something actually changes. Latency drops from "however often you poll" to "the instant it happens."
- Save src/ws/server.ts.
- Stop your server with Ctrl+C and restart it by running:
npx tsx src/index.ts
- In your second terminal, reconnect wscat by running:
npx wscat -c ws://localhost:3001
- Open a third terminal and place a buy order by running:
curl -X POST http://localhost:3000/api/orders -H "Content-Type: application/json" -d '{"userId":"user1","pair":"BTC-USD","side":"BUY","price":50000,"quantity":0.5}'
Switch back to your wscat terminal. You should see an orderbook update message appear instantly:
< {"type":"orderbook","data":{"bids":[{"price":50000,"quantity":0.5}],"asks":[]}}
The buy order at $50,000 for 0.5 BTC now shows in the bids. You did not refresh or poll. The server pushed the update the moment the order landed.
Not seeing updates in wscat?
- Make sure you restarted the server after saving the broadcast changes.
- Confirm wscat is connected (you should have seen the initial snapshot message).
- Check that the curl command uses user IDs that exist in your seeded database. If you re-ran the seed, the UUIDs may have changed. Use the actual UUID from your seed output.
Why aren't my WebSocket broadcasts showing up?
Test the full flow end-to-end
Time to test the complete loop: place a matching pair of orders and watch the trade execute in real time over WebSocket.
- Make sure wscat is still connected in your second terminal. If not, reconnect by running:
npx wscat -c ws://localhost:3001
- In your third terminal, place a sell order that matches the existing buy order by running:
curl -X POST http://localhost:3000/api/orders -H "Content-Type: application/json" -d '{"userId":"user2","pair":"BTC-USD","side":"SELL","price":50000,"quantity":0.3}'
Before you check wscat, predict what you expect to see. The sell order is for 0.3 BTC at $50,000, and there is a buy resting at $50,000 for 0.5 BTC. Will the entire buy fill?
Switch to your wscat terminal. You should see two messages arrive:
- A trades message showing the executed trade for 0.3 BTC at $50,000.
- An orderbook message showing the remaining buy order with only 0.2 BTC left.
- Verify the order book state via REST by running in your third terminal:
curl http://localhost:3000/api/orderbook/BTC-USD
You should see the buy side showing 0.2 BTC remaining at $50,000, confirming the partial fill.
- Verify the trade was recorded by running:
curl http://localhost:3000/api/trades/BTC-USD
You should see a trade record for 0.3 BTC at price 50000.
What just happened?
The sell order arrived, the matching engine found a compatible buy (buy price >= sell price), executed a trade for 0.3 BTC at the resting order's price, updated both wallets atomically, and the WebSocket server broadcast the trade and updated order book to your connected client.
All of this happened in a single request cycle. No polling. No delay.
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/ws/server.ts and src/index.ts.
ⓧ I'd like to double check the full code
Here is the complete src/ws/server.ts:
import { WebSocketServer, WebSocket } from "ws";
import { engine } from "../engine/matcher.js";
export function startWebSocketServer(port: number): WebSocketServer {
const wss = new WebSocketServer({ port });
const clients = new Set<WebSocket>();
wss.on("connection", (ws: WebSocket) => {
clients.add(ws);
const snapshot = engine.getDepth("BTC-USD");
ws.send(JSON.stringify({ type: "orderbook", data: snapshot }));
ws.on("close", () => {
clients.delete(ws);
});
});
function broadcast(message: string): void {
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
}
engine.on("orderbook", (data: { symbol: string; depth: object }) => {
broadcast(JSON.stringify({ type: "orderbook", data: data.depth }));
});
engine.on("trades", (data: { symbol: string; trades: object[] }) => {
broadcast(JSON.stringify({ type: "trades", data: data.trades }));
});
console.log(`WebSocket server running on ws://localhost:${port}`);
return wss;
}
Here is the complete src/index.ts:
import "dotenv/config";
import express from "express";
import ordersRouter from "./routes/orders.js";
import marketRouter from "./routes/market.js";
import { engine } from "./engine/matcher.js";
import { startWebSocketServer } from "./ws/server.js";
const app = express();
const PORT = 3000;
const WS_PORT = 3001;
app.use(express.json());
app.get("/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
app.use("/api/orders", ordersRouter);
app.use("/api", marketRouter);
async function start() {
await engine.loadFromDatabase();
startWebSocketServer(WS_PORT);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
}
start().catch(console.error);
Your exchange now pushes live data to every connected client the instant something changes. You have a REST API for submitting orders and querying state, a matching engine that pairs orders using price-time priority, and a WebSocket layer that streams it all in real time. That is the core architecture behind every centralized exchange.
Secret mission
Add Market Orders
Limit orders are great for patient traders, but what about someone who wants to buy right now at any price? Add market orders that sweep through the order book from best price outward, executing instantly against resting liquidity. If there is not enough liquidity to fill the full quantity, the unfilled remainder is cancelled rather than resting in the book.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- PostgreSQL database exchange running locally on your machine.
- Project folder exchange-engine on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or plan to add a frontend in a follow-up project.
- Your PostgreSQL service stays running in the background and uses minimal resources.
- Your project folder and node_modules remain on disk.
Pause - I'll come back to this later
Stop the PostgreSQL service to free up memory. Your database and project files stay on disk for when you return.
- Stop the PostgreSQL service by running:
- When you are ready to resume, start it again with:
sudo systemctl stop postgresql
To restart later:
sudo systemctl start postgresql
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your database and project files.
- Drop the exchange database by running:
sudo -u postgres dropdb exchange
- Optionally remove PostgreSQL 18 from your system by running:
sudo apt remove postgresql-18
- Delete the project folder by running:
rm -rf ~/Desktop/exchange-engine
Nice Work!
Nice Work!
Nice work! You just built a fully functional crypto exchange matching engine from scratch. Orders go in, trades come out, and every connected client sees it happen in real time.
You've learned how to:
- Build a matching engine that pairs buy and sell orders using price-time priority and executes trades atomically with wallet balance settlement.
- Create a REST API that validates orders, locks funds in user wallets, and returns live order book depth grouped by price level.
- Stream real-time trade executions and order book updates to connected clients over WebSocket using Node.js EventEmitter to decouple the engine from the broadcast layer.
- Secret Mission: Extended the engine with market orders that sweep multiple price levels and cancel unfilled remainders when liquidity runs out.
Ready to quiz yourself?