# Building a Mini Centralized Exchange (CEX) with Bun, TypeScript, Redis, and PostgreSQL

### Architecture of Centralized Exchange

Before diving into the implementation details, let's look at the overall architecture of the exchange. This high-level view shows how the different services interact, how requests flow through the system, and where the matching engine fits into the picture.

The matching engine acts as the source of truth, while Redis is used for asynchronous communication between services. PostgreSQL stores persistent application data, and periodic snapshots ensure the in-memory state can be recovered after failures.

Link: [https://excalidraw.com/#json=HUQD7tEI-J8uU8c1-IgCp,JbifdE1b30P-q964hgRMvg](https://excalidraw.com/#json=HUQD7tEI-J8uU8c1-IgCp,JbifdE1b30P-q964hgRMvg)

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/27740831-c3b3-4e5b-9e66-b6e57c36793b.gif align="center")

Throughout the rest of this article, we'll explore each of these components in detail, starting with the core concepts behind a centralized exchange and the functionality our matching engine needs to support.

* * *

### Functional Requirements of a Centralized Exchange

Before implementing the matching engine, let's understand how a real centralized exchange works and what functionality we need to support.

**What is a Centralized Exchange?**

A Centralized Exchange (CEX) is a platform where buyers and sellers trade assets through a central authority.

Examples:

*   Binance
    
*   Backpack Exchange
    
*   Coinbase
    
*   Bombay Stock Exchange (BSE)
    
*   National Stock Exchange (NSE)
    

The exchange acts as a trusted middleman and is responsible for:

*   Maintaining user balances
    
*   Managing order books
    
*   Matching buy and sell orders
    
*   Recording trades
    
*   Settling balances
    

Most centralized exchanges require KYC (Know Your Customer) before users can trade.

* * *

**Brokers vs Exchanges**

An exchange is where actual matching happens.

Examples:

*   Binance
    
*   Backpack
    
*   BSE
    
*   NSE
    

**Broker**

A broker provides a user-friendly interface to the exchange.

Examples:

*   Zerodha
    
*   Groww
    

Think of it this way:

```plaintext
User
  |
  v
Broker (Zerodha)
  |
  v
Exchange (NSE)
```

In this project, we're building the exchange itself, not the broker.

* * *

**Understanding the Order Book**

The order book is the heart of every exchange.

Think of it like a real estate broker's notebook.

The broker keeps track of:

*   People willing to buy land
    
*   People willing to sell land
    

Similarly, an order book keeps track of:

**Bids**

People willing to buy.

```plaintext
Buy SOL at $82.85
Buy SOL at $82.84
Buy SOL at $82.83
```

**Asks**

People willing to sell.

```plaintext
Sell SOL at $82.86
Sell SOL at $82.87
Sell SOL at $82.88
```

The order book constantly changes as new orders arrive and existing orders get matched.

* * *

**Limit Orders**

A limit order specifies the exact price at which a user wants to trade.

Example:

```plaintext
Buy 10 SOL at $82.85
```

The order will only execute if someone is willing to sell at $82.85.

Otherwise, it remains in the order book.

* * *

**Market Orders**

A market order executes immediately at the best available price.

Example:

```plaintext
Buy 20 SOL at market price
```

The exchange automatically matches against the cheapest available sell orders.

Suppose the order book contains:

```plaintext
Ask Side

82.86 -> 159.71 SOL
82.87 -> 100 SOL
82.88 -> 50 SOL
```

If a user places:

```plaintext
Buy 20 SOL at Market
```

The exchange immediately fills:

```plaintext
20 SOL @ 82.86
```

* * *

**Partial Fills**

Not every order can be completely matched.

Example:

```plaintext
Buy 1000 SOL @ 82.85
```

Available liquidity:

```plaintext
82.85 -> 337.15 SOL
```

Only part of the order gets filled.

```plaintext
Filled = 337.15 SOL
Remaining = 662.85 SOL
```

The remaining quantity stays on the order book waiting for future sellers.

* * *

**Maker vs Taker**

**Maker**

Creates liquidity.

Example:

```plaintext
Buy 10 SOL @ 82.80
```

No seller exists.

Order sits in the order book.

This user is a Maker.

**Taker**

Consumes liquidity.

Example:

```plaintext
Buy 10 SOL at Market
```

Immediately matches with existing sellers.

This user is a Taker.

Exchanges usually charge lower fees to Makers because they provide liquidity.

* * *

### What Should Be Stored in Memory vs Database?

**In-Memory Data**

The matching engine needs extremely fast access to data.

Because orders are created, matched, and cancelled continuously, storing this information directly in PostgreSQL would introduce:

*   High latency
    
*   Frequent database writes
    
*   Lock contention
    
*   Concurrency issues
    

Therefore, the matching engine keeps the following data structures in memory:

**User Balances**

```plaintext
balances = {
  user1: {
    usd: {
      available: 20,
      locked: 10
    }
  }
}
```

**Order Books**

```plaintext
orderBooks = {
  SOL: {
    bids: [],
    asks: [],
    lastTradedPrice: 0
  },

  BTC: {
    bids: [],
    asks: [],
    lastTradedPrice: 0
  }
}
```

This allows order matching to happen in microseconds without querying the database.

* * *

**Database Schema**

Even though matching happens in memory, we still need PostgreSQL for persistence.

**Users**

```js

model User {
  id       String @id @default(uuid())
  username String @unique
  password String
}
```

* * *

### APIs to Implement

**Create Order**

```plaintext
POST /order
```

* * *

**Get Order Details**

```plaintext
GET /order/:orderId
```

Returns:

*   Order information
    
*   Fill history
    
*   Filled quantity
    
*   Remaining quantity
    
*   Order status
    

* * *

**Cancel Order**

```plaintext
DELETE /order/:orderId
```

Cancels the unfilled portion of an order.

Example:

```plaintext
Original Order = 100 SOL

Filled = 40 SOL

Remaining = 60 SOL
```

Only the remaining 60 SOL is removed from the order book.

* * *

**Get Market Depth**

```plaintext
GET /depth/:symbol
```

Returns:

*   Top 20 bids
    
*   Top 20 asks
    

Example:

```plaintext
SOL Order Book

Bids:
82.85
82.84
82.83

Asks:
82.86
82.87
82.88
```

* * *

### Architecture Overview

Our Mini Centralized Exchange follows an event-driven architecture:

```plaintext
Frontend / API Client
        |
        v
Backend API (Express)
        |
        v
Redis Queue (backend-to-engine-broker)
        |
        v
Matching Engine Process
        |
        v
Backend-Specific Response Queue
        |
        v
Backend API Response
```

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/16e8337d-2a0f-4bee-8d32-e35e4549868b.png align="center")

* * *

### Tech Stack

*   TypeScript
    
*   Bun
    
*   Express
    
*   Redis
    
*   Prisma
    
*   PostgreSQL
    
*   JWT Authentication
    
*   Zod Validation
    

* * *

### Step 1: **Initialize the Backend Project**

Create a new backend directory and initialize a Bun project:

```plaintext
mkdir backend
cd backend
bun init
```

This generates the basic project structure along with a `package.json` file.

* * *

### Step2: Set Up Prisma with PostgreSQL

We'll use Prisma as our ORM and PostgreSQL as the primary database.

Instead of manually configuring everything, follow the official Prisma Bun guide:

[https://www.prisma.io/docs/guides/runtimes/bun](https://www.prisma.io/docs/guides/runtimes/bun)

The guide covers:

*   Installing Prisma
    
*   Initializing Prisma
    
*   Connecting PostgreSQL
    
*   Creating your first schema
    
*   Running migrations
    
*   Generating the Prisma Client
    

* * *

### Step3: Install Initial Dependencies

Before writing any code, let's install only the dependencies required to start our Express server and manage environment variables.

```typescript
bun add express cors dotenv
bun add -d typescript @types/bun @types/express @types/cors
```

We'll install additional dependencies such as Prisma, PostgreSQL, Redis, JWT, Zod, and bcrypt as we need them throughout the project.

* * *

**Project Structure**

At this stage, our project structure looks like this:

```plaintext
backend/
├── src/
│   ├── index.ts
│   └── utils/
│       └── env.ts
├── .env
├── .gitignore
├── bun.lock
├── package.json
├── prisma.config.ts
├── README.md
└── tsconfig.json
```

All application code will live inside the `src` directory.

* * *

### Step4: Creating the Express Server

Create `src/index.ts`:

```js
import cors from "cors";
import express, {
  type NextFunction,
  type Request,
  type Response,
} from "express";
import { env } from "./utils/env.js";

const app = express();

app.use(cors());
app.use(express.json());

app.get("/health", async (_req, res) => {
  res.json({ ok: true });
});

app.use(
  (err: unknown, _req: Request, res: Response, _next: NextFunction) => {
    console.error(err);

    res.status(500).json({
      error: err instanceof Error ? err.message : "internal_server_error",
    });
  },
);

app.listen(env.port, () => {
  console.log(`Backend running on http://localhost:${env.port}`);
});
```

This gives us:

*   CORS support for frontend requests
    
*   JSON request parsing
    
*   A health check endpoint
    
*   Global error handling middleware
    
*   A configurable server port
    

* * *

### Step 5: Managing Environment Variables

Create `src/utils/env.ts`:

```typescript
import "dotenv/config";

function readRequiredEnv(name: string): string {
  const value = process.env[name];

  if (!value) {
    throw new Error(`Missing required env variable: ${name}`);
  }

  return value;
}

export const env = {
  port: Number(process.env.PORT ?? "3000"),
};
```

Loading environment variables through a dedicated module keeps configuration centralized and makes it easier to validate required values as the application grows.

* * *

### Step 6: Create a .env File

```typescript
PORT=3000
```

* * *

### Step 7: Start the Server

Run the development server:

```plaintext
bun run dev
```

If everything is configured correctly, you should see:

```plaintext
Backend running on http://localhost:3000
```

You can verify the server is working by visiting:

```plaintext
http://localhost:3000/health
```

Expected response:

```plaintext
{
  "ok": true
}
```

* * *

### Step8: Creating the Signup API

Now that our Express server is running and connected to PostgreSQL through Prisma, let's implement the first authentication endpoint: **Signup**.

The signup flow will:

1.  Validate incoming request data using Zod.
    
2.  Hash the user's password using bcrypt.
    
3.  Store the user in PostgreSQL.
    
4.  Generate a JWT token.
    
5.  Return the authenticated user information.
    

* * *

**Register the Application Router**

First, update `src/index.ts` and register the application's root router.

```javascript
import { appRouter } from "./routes/index.js";

app.use(appRouter);
```

This keeps route definitions separate from server initialization, making the project easier to scale as new features are added.

* * *

**Create the Root Router**

Create `src/routes/index.ts`:

```typescript
import { Router } from "express";
import { authRouter } from "./auth-routes.js";

export const appRouter = Router();

appRouter.use(authRouter);
```

The root router acts as a central place where all feature-specific routers are registered.

* * *

**Create the Authentication Router**

Create `src/routes/auth-routes.ts`:

```javascript
import { Router } from "express";
import { signup } from "../controllers/auth-controller.js";
import { asyncHandler } from "../utils/async-handler.js";

export const authRouter = Router();

authRouter.post("/signup", asyncHandler(signup));
```

Instead of placing business logic directly inside route handlers, we delegate it to a controller.

* * *

**Create an Async Handler**

Express does not automatically catch errors thrown inside async functions. To avoid repetitive try/catch blocks, create a reusable async wrapper.

Create `src/utils/async-handler.ts`:

```javascript
import type {
  NextFunction,
  Request,
  RequestHandler,
  Response,
} from "express";

export function asyncHandler(
  handler: (
    req: Request,
    res: Response,
    next: NextFunction,
  ) => Promise<void>,
): RequestHandler {
  return function wrappedHandler(req, res, next) {
    void handler(req, res, next).catch(next);
  };
}
```

Any unhandled error will automatically reach Express's global error middleware.

* * *

**Define the Request Schema**

Before creating users, we should validate incoming data.

Create `src/types/auth-schema.ts`:

```javascript
import { z } from "zod";

export const authSchema = z.object({
  username: z.string().trim().min(1, "username is required"),
  password: z.string().min(1, "password is required"),
});
```

Using Zod ensures invalid requests never reach our database layer.

* * *

**Create a Validation Helper**

Create `src/utils/validation.ts`:

```plaintext
import type { Response } from "express";
import type { ZodError } from "zod";

export function sendValidationError(
  res: Response,
  error: ZodError,
): void {
  res.status(400).json({
    error: "validation_error",
    issues: error.issues.map((issue) => ({
      path: issue.path.join("."),
      message: issue.message,
    })),
  });
}
```

This gives clients a consistent error format whenever validation fails.

* * *

**Create JWT Utilities**

Create `src/utils/auth.ts`:

```javascript
import jwt from "jsonwebtoken";
import { env } from "./env.js";

export interface TokenPayload {
  userId: string;
}

export function createToken(payload: TokenPayload): string {
  return jwt.sign(payload, env.jwtSecret, {
    expiresIn: "7d",
  });
}
```

We'll use this helper throughout the application whenever a JWT token needs to be generated.

* * *

**Create the Signup Controller**

Create `src/controllers/auth-controller.ts`:

```typescript
import bcrypt from "bcryptjs";
import type { Request, Response } from "express";
import { prisma } from "../db.js";
import { authSchema } from "../types/auth-schema.js";
import { createToken } from "../utils/auth.js";
import { sendValidationError } from "../utils/validation.js";

export async function signup(
  req: Request,
  res: Response,
): Promise<void> {
  const parsedBody = authSchema.safeParse(req.body);

  if (!parsedBody.success) {
    sendValidationError(res, parsedBody.error);
    return;
  }

  const { username, password } = parsedBody.data;

  const hashedPassword = await bcrypt.hash(password, 10);

  try {
    const user = await prisma.user.create({
      data: {
        username,
        password: hashedPassword,
      },
    });

    res.status(201).json({
      token: createToken({
        userId: user.id,
      }),
      userId: user.id,
      username: user.username,
    });
  } catch {
    res.status(409).json({
      error: "username already exists",
    });
  }
}
```

This controller validates the request, hashes the password, creates the user, and immediately returns an authentication token.

* * *

**Required Environment Variables**

Update your `.env` file:

```javascript
PORT=3000
JWT_SECRET=super-secret-key
```

Also expose the secret in `src/utils/env.ts`:

```typescript
export const env = {
  port: Number(process.env.PORT ?? "3000"),
  jwtSecret: readRequiredEnv("JWT_SECRET"),
};
```

* * *

**Testing the Endpoint**

Send a request to:

```plaintext
POST /signup
```

Request body:

```javascript
{
  "username": "shubham",
  "password": "password123"
}
```

Successful response:

```javascript
{
  "token": "<jwt-token>",
  "userId": "user-id",
  "username": "shubham"
}
```

At this point, users can successfully create accounts and receive a JWT token that can be used for authenticated requests throughout the exchange.

* * *

### Step9: Creating the Signin API

With user registration complete, let's implement the **Signin API**.

The signin flow will:

1.  Validate the request body.
    
2.  Find the user by username.
    
3.  Verify the password using bcrypt.
    
4.  Generate a JWT token.
    
5.  Return the authenticated user's information.
    

* * *

**Register the Signin Route**

Update `src/routes/auth-routes.ts`:

```javascript
import { Router } from "express";
import { signin, signup } from "../controllers/auth-controller.js";
import { asyncHandler } from "../utils/async-handler.js";

export const authRouter = Router();

authRouter.post("/signup", asyncHandler(signup));
authRouter.post("/signin", asyncHandler(signin));
```

We now expose two authentication endpoints:

```plaintext
POST /signup
POST /signin
```

* * *

**Create the Signin Controller**

Update `src/controllers/auth-controller.ts`:

```typescript
export async function signin(
  req: Request,
  res: Response,
): Promise<void> {
  const parsedBody = authSchema.safeParse(req.body);

  if (!parsedBody.success) {
    sendValidationError(res, parsedBody.error);
    return;
  }

  const { username, password } = parsedBody.data;

  const userExists = await prisma.user.findFirst({
    where: {
      username,
    },
  });

  if (!userExists) {
    res.status(401).json({
      error: "username not exists",
    });

    return;
  }

  const correctPassword = await bcrypt.compare(
    password,
    userExists.password,
  );

  if (!correctPassword) {
    res.status(403).json({
      error: "password is invalid",
    });

    return;
  }

  res.status(201).json({
    token: createToken({
      userId: userExists.id,
    }),
    userId: userExists.id,
    username: userExists.username,
  });
}
```

* * *

**How the Signin Flow Works**

```plaintext
Client
  |
  | POST /signin
  v
Validate Request (Zod)
  |
  v
Find User (Prisma)
  |
  v
Compare Password (bcrypt)
  |
  v
Generate JWT
  |
  v
Return Token
```

Unlike the signup endpoint, we do not create a new user. Instead, we verify the supplied credentials and issue a new JWT token if authentication succeeds.

* * *

**Testing the Endpoint**

Send a request to:

```plaintext
POST /signin
```

Request body:

```javascript
{
  "username": "shubham",
  "password": "password123"
}
```

Successful response:

```javascript
{
  "token": "<jwt-token>",
  "userId": "user-id",
  "username": "shubham"
}
```

If the username does not exist:

```typescript
{
  "error": "username not exists"
}
```

If the password is incorrect:

```python
{
  "error": "password is invalid"
}
```

At this point, users can register, sign in, and receive JWT tokens that will be used to access protected exchange APIs in the upcoming sections.

* * *

### Step10: Connecting the Backend and Engine with Redis

Now that authentication is working, it's time to connect our backend service to the matching engine.

Instead of calling the engine through HTTP, we'll communicate through Redis queues.

This gives us an event-driven architecture where the backend and engine are completely independent services.

**Architecture**

```plaintext
Frontend
    |
    v
Backend API
    |
    v
Redis Queue
(backend-to-engine-broker)
    |
    v
Matching Engine
    |
    v
Response Queue
(response-queue-123)
    |
    v
Backend API
    |
    v
Frontend
```

When a user places an order:

1.  Backend validates the request.
    
2.  Backend sends a message to Redis.
    
3.  Engine consumes the message.
    
4.  Engine processes the order.
    
5.  Engine sends a response back.
    
6.  Backend returns the response to the client.
    

* * *

**Install Redis**

In the backend project:  
`backend/`

```plaintext
bun add redis
```

In the engine project:  
`engine/`

```plaintext
bun add redis
```

* * *

**Create a Redis Database**

You can run Redis locally or use a managed provider.

For this project we'll use:

[https://upstash.com/](https://upstash.com/)

Create a Redis database and copy the connection URL.

* * *

**Configure Environment Variables**

Update your `.env` file:

```hs
REDIS_URL="your-redis-url" 
JWT_SECRET="your-secret" 
PORT=3000 
INCOMING_QUEUE="backend-to-engine-broker" BACKEND_QUEUE_ID="1234" 
ENGINE_TIMEOUT_MS=30000
```

Update `src/utils/env.ts`:

```js
export const env = {
  port: Number(process.env.PORT ?? "3000"),
  redisUrl: readRequiredEnv("REDIS_URL"),
  jwtSecret: readRequiredEnv("JWT_SECRET"),

  incomingQueue:
    process.env.INCOMING_QUEUE ??
    "backend-to-engine-broker",

  responseQueue:
    `response-queue-${
      process.env.BACKEND_QUEUE_ID ??
      crypto.randomUUID()
    }`,

  engineTimeoutMs: Number(
    process.env.ENGINE_TIMEOUT_MS ?? "30000",
  ),
};
```

**Why do we need two queues?**

**Incoming Queue**

Used by the backend to send requests.

```plaintext
backend-to-engine-broker
```

#### Response Queue

Used by the engine to send responses back.

```plaintext
response-queue-1234
```

Every backend instance gets its own response queue.

* * *

**Create the Redis Client**

Create: `src/utils/engine-client.ts`

We'll create two Redis connections.

```javascript
const publisher = createClient({
  url: env.redisUrl,
});

const subscriber = createClient({
  url: env.redisUrl,
});
```

**Publisher**

Used to send messages to the engine.

```javascript
await publisher.lPush(
  env.incomingQueue,
  JSON.stringify(message),
);
```

**Subscriber**

Used to wait for engine responses.

```javascript
await subscriber.brPop(
  env.responseQueue,
  0,
);
```

**Why two Redis clients?**

The subscriber uses a blocking operation:

```plaintext
await subscriber.brPop(...)
```

While waiting, that connection is blocked.

Using a separate publisher allows us to continue sending messages normally.

* * *

**Connect Redis During Startup**

Update `src/index.ts`.

Before starting Express:

```javascript
await connectRedis();

void listenForEngineResponses();
```

Full startup sequence:

```plaintext
Start Backend
      |
      v
Connect Redis
      |
      v
Start Listening For Responses
      |
      v
Start Express Server
```

We also update our health endpoint:

```javascript
app.get("/health", async (_req, res) => {
  await pingRedis();

  res.json({
    ok: true,
  });
});
```

Now health checks verify Redis connectivity.

* * *

**Creating Request-Response Communication**

When we send a message to Redis we need a way to know which response belongs to which request.

For that we use a Correlation ID.

```csharp
const correlationId = crypto.randomUUID();
```

Example message:

```typescript
{
  "correlationId": "123",
  "responseQueue": "response-queue-1234",
  "type": "create_order",
  "payload": {
    "symbol": "BTC",
    "qty": 1
  }
}
```

Then we push it to Redis:

```typescript
await publisher.lPush(
  env.incomingQueue,
  JSON.stringify(message),
);
```

* * *

**Tracking Pending Requests**

After sending a request, the backend must wait for the engine's response.

Create:

```javascript
src/store/pending-responses.ts
```

We store pending requests in memory.

```typescript
const pendingResponses = new Map<string, PendingResponse>();
```

Structure:

```plaintext
Correlation ID
       |
       v
Promise Resolver
```

When a request is sent:

```typescript
waitForEngineResponse(
  correlationId,
  timeoutMs,
);
```

we save its promise resolver.

Later, when the engine responds, we resolve the correct promise.

This allows multiple requests to be processed simultaneously.

* * *

**Listening for Engine Responses**

The backend continuously waits for messages.

```typescript
for (;;) {
  const response =
    await subscriber.brPop(
      env.responseQueue,
      0,
    );
}
```

`BRPOP` means:

> Block until a message arrives.

When a response arrives:

```plaintext
resolveEngineResponse(
  parsedResponse,
);
```

the correct pending request is completed.

* * *

**Using the Engine from an API**

Let's connect everything to our order endpoint.

update `src/routes/index.ts`

```typescript
import { Router } from "express";
import { authRouter } from "./auth-routes.js";
import { exchangeRouter } from "./exchange-routes.js";

export const appRouter = Router();

appRouter.use(authRouter);
appRouter.use(exchangeRouter);
```

create `src/routes/exchange-routes.ts`

```js
import { Router } from "express";
import {
  cancelOrder,
} from "../controllers/exchange-controller.js";
import { requireAuth } from "../utils/auth.js";
import { asyncHandler } from "../utils/async-handler.js";

export const exchangeRouter = Router();

exchangeRouter.post("/order", requireAuth, asyncHandler(createOrder));
```

Controller: `src/controllers/exchange-controller.ts`

```js
export async function createOrder(
  req: Request,
  res: Response,
): Promise<void> {
  const engineResponse =
    await sendToEngine(
      "create_order",
      {
        userId,
        type,
        side,
        symbol,
        price,
        qty,
      },
    );

  res.status(
    engineResponse.ok ? 200 : 400,
  ).json(
    engineResponse.ok
      ? engineResponse.data
      : {
          error:
            engineResponse.error,
        },
  );
}
```

Notice something important:

The backend does not contain any matching logic.

Its only responsibilities are:

1.  Authenticate the user
    
2.  Validate the request
    
3.  Send a message to Redis
    
4.  Wait for a response
    
5.  Return the result
    

The engine handles all trading logic.

* * *

**Building the Engine Service**

Create a separate `engine` project.

Install dependencies:

```javascript
bun add redis
```

* * *

**Configure Environment Variables**

Create `.env`:

```typescript
REDIS_URL="your-redis-url"

INCOMING_QUEUE="backend-to-engine-broker"
```

Create `src/utils/env.ts`:

```typescript
import "dotenv/config";

function readRequiredEnv(name: string): string {
  const value = process.env[name];

  if (!value) {
    throw new Error(
      `Missing required env variable: ${name}`,
    );
  }

  return value;
}

export const env = {
  redisUrl: readRequiredEnv("REDIS_URL"),

  incomingQueue:
    process.env.INCOMING_QUEUE ??
    "backend-to-engine-broker",
};
```

Notice that we only configure `INCOMING_QUEUE`.

There is no `RESPONSE_QUEUE` here.

Why?

Because the engine doesn't have a fixed response queue.

Instead, the backend tells the engine which queue to respond to.

* * *

**How the Backend Sends Requests**

When the backend publishes a message, it includes:

```typescript
{
  "correlationId": "123",
  "responseQueue": "response-queue-1234",
  "type": "create_order",
  "payload": {}
}
```

Notice the `responseQueue` field.

The backend is saying:

> Process this request and send the response back to `response-queue-1234`.

This allows multiple backend instances to share the same engine.

* * *

**Redis Connections**

Inside the engine we create two Redis clients:

```typescript
const brokerClient =
  createClient({
    url: env.redisUrl,
  });

const responseClient =
  createClient({
    url: env.redisUrl,
  });
```

**Why Two Redis Clients?**

**brokerClient**

Used to consume messages from:

```plaintext
backend-to-engine-broker
```

Example:

```typescript
await brokerClient.brPop(
  env.incomingQueue,
  0,
);
```

This client continuously waits for requests coming from the backend.

**responseClient**

Used to publish responses back to the backend.

Example:

```javascript
await responseClient.lPush(
  responseQueue,
  JSON.stringify(response),
);
```

We keep publishing and consuming on separate connections because `BRPOP` is a blocking operation.

* * *

**Listening For Requests**

The engine continuously waits for messages:

```javascript
for (;;) {
  const item =
    await brokerClient.brPop(
      env.incomingQueue,
      0,
    );
}
```

Flow:

```typescript
Backend
    |
    v
backend-to-engine-broker
    |
    v
brokerClient
```

Whenever a message arrives:

```typescript
{
  "type": "create_order"
}
```

the engine begins processing it.

* * *

**Processing the Request**

After receiving a message:

```js
const data = handleEngineRequest(message);
```

the engine executes the appropriate business logic.

For example:

```plaintext
create_order
      |
      v
Order Book
      |
      v
Matching Logic
      |
      v
Trade Result
```

The result is stored in `data`.

* * *

**Sending Responses Back**

After processing finishes:

```javascript
await sendResponse(
  message.responseQueue,
  {
    correlationId:
      message.correlationId,
    ok: true,
    data,
  },
);
```

Notice that we are not sending the response to a hardcoded queue.

Instead we use:

```javascript
message.responseQueue
```

which came from the backend request.

For example:

```javascript
{
  "responseQueue":
    "response-queue-1234"
}
```

The response is therefore pushed into:

```plaintext
response-queue-1234
```

* * *

**Why Send the Correlation ID Back?**

The engine also returns:

```javascript
{
  correlationId:
    message.correlationId
}
```

Example:

```plaintext
{
  "correlationId": "123",
  "ok": true,
  "data": {}
}
```

The backend uses this correlation ID to identify which pending request should receive the response.

Without it, the backend wouldn't know which API request is waiting for this result.

* * *

**Complete Flow**

```plaintext
User
 |
 | POST /order
 v
createOrder()
 |
 | validate request
 v
sendToEngine()
 |
 | create correlationId
 | store Promise resolver
 v
pendingResponses Map
(correlationId -> Promise)
 |
 | LPUSH
 v
Redis Queue
backend-to-engine-broker
 |
 | BRPOP
 v
brokerClient (Engine)
 |
 v
handleEngineRequest()
 |
 | process order
 v
sendResponse()
 |
 | LPUSH
 v
Response Queue
response-queue-1234
 |
 | BRPOP
 v
listenForEngineResponses()
 |
 | resolveEngineResponse()
 v
pendingResponses Map
 |
 | resolve Promise
 v
sendToEngine() returns
 |
 v
createOrder()
 |
 v
HTTP Response
 |
 v
User
```

This gives us a full request-response pattern over Redis while keeping the backend and engine completely decoupled.

* * *

### Step 11: Implementing Deposits in the Exchange

Before implementing order matching, I needed a way for users to add funds to their trading accounts.

When a user places a buy order, the engine must verify that the user actually has enough balance available.

For example:

```plaintext
User deposits $1000

Balance:
USD
├─ Available: 1000
└─ Locked: 0
```

Now suppose the user creates a limit buy order:

```plaintext
Buy 2 SOL @ $100
```

The engine needs to reserve (lock) funds for that order.

```plaintext
USD
├─ Available: 800
└─ Locked: 200
```

Because balance validation happens on every order request, balances need to be available instantly.

Querying PostgreSQL for every order would add unnecessary latency and create concurrency issues.

That's why most centralized exchanges keep balances in memory inside the matching engine.

* * *

**A Question About Architecture**

Initially, my idea was:

```plaintext
POST /deposit
    |
    v
Backend updates PostgreSQL
    |
    v
Publish balance_updated event
    |
    v
Engine updates in-memory balance
```

The backend/database would be the source of truth, while the engine would simply consume balance updates.

I asked others whether this approach was correct.

The feedback was:

> The engine must be the source of truth. Send updates to the engine first, then persist them asynchronously.

At first this seemed confusing, but after thinking through a real example it made sense.

* * *

**Why the Engine Must Be the Source of Truth**

Imagine a user deposits:

```plaintext
$1000
```

Database:

```plaintext
Balance = $1000
```

Now the user creates an order worth:

```plaintext
$200
```

The engine locks those funds:

```plaintext
Available = $800
Locked = $200
```

But if the database hasn't been updated yet:

```plaintext
Database = $1000
Engine   = $800 available
```

Now another deposit arrives.

If the backend reads the stale database value and calculates the new balance from it, the balance becomes incorrect.

The engine and database are no longer synchronized.

This is exactly why the matching engine must own all balance changes.

* * *

**Final Architecture**

The flow now becomes:

```plaintext
POST /deposit
    |
    v
Backend
    |
    v
Redis Queue
    |
    v
Engine
    |
    v
Update In-Memory Balance
    |
    v
Send Response
    |
    v
Persist Snapshot Later
```

The engine becomes responsible for:

*   Deposits
    

Every balance mutation happens in one place.

This prevents race conditions and keeps balances consistent.

* * *

**Implementing the Deposit API**

Added a new route:

```typescript
exchangeRouter.post(
  "/deposit",
  requireAuth,
  asyncHandler(depositMoney),
);
```

Validation schema:

```javascript
export const depositSchema = z.object({
  asset: z.string().trim().min(1, "asset is required"),
  amount: z.number().positive("amount must be positive"),
});
```

Controller:

```javascript
export async function depositMoney(
  req: Request,
  res: Response,
): Promise<void> {
  const userId = getUserId(req);

  const parsedBody = depositSchema.safeParse(req.body);

  if (!parsedBody.success) {
    sendValidationError(res, parsedBody.error);
    return;
  }

  const { asset, amount } = parsedBody.data;

  const engineResponse = await sendToEngine("deposit", {
    userId,
    asset,
    amount,
  });

  res.status(engineResponse.ok ? 200 : 400).json(
    engineResponse.ok
      ? engineResponse.data
      : {
          error: engineResponse.error,
        },
  );
}
```

* * *

**Handling Deposits Inside the Engine**

A new engine command was added:

```javascript
export type EngineCommandType =
  | "deposit"
  | "create_order"
  | "get_depth"
  | "get_user_balance"
  | "get_order"
  | "cancel_order";
```

When the engine receives a deposit request, it updates its in-memory balance store:

```javascript
export function handleDeposit(
  payload: Record<string, unknown>,
) {
  const userId = payload.userId as string;
  const asset = payload.asset as string;
  const amount = Number(payload.amount);

  let balances = BALANCES.get(userId);

  if (!balances) {
    balances = {};
    BALANCES.set(userId, balances);
  }

  const balance = balances[asset] ?? {
    available: 0,
    locked: 0,
  };

  balance.available += amount;

  balances[asset] = balance;

  return {
    userId,
    asset,
    balance,
  };
}
```

* * *

**Deposit Request Flow**

```typescript
POST /deposit
    |
    v
depositMoney()
    |
    v
sendToEngine("deposit")
    |
    | LPUSH
    v
Redis Queue
backend-to-engine-broker
    |
    v
Engine
    |
    v
handleDeposit()
    |
    v
BALANCES Map Updated
    |
    v
Response Queue
response-queue-1234
    |
    v
Backend Listener
    |
    v
HTTP Response
```

With deposits working, the engine now owns balance management and is ready for the next step: validating balances and locking funds during order creation.

* * *

### Step 12: Lets Implement Limit Order Matching Engine from Scratch

**1\. Creating an Order Record**

Every order gets a unique ID and is stored before matching begins.

```javascript
const { orderId, order } = createLimitOrderRecord(input);
```

Implementation:

```javascript
export function createLimitOrderRecord(input: CreateOrderInput) {
  const orderId = crypto.randomUUID();

  const order: OrderRecord = {
    orderId,
    userId: input.userId,
    side: input.side,
    type: input.type,
    symbol: input.symbol,
    price: input.price,
    qty: input.qty,
    filledQty: 0,
    status: "open",
    fills: [],
    createdAt: Date.now(),
  };

  return { orderId, order };
}
```

* * *

**2\. Locking User Balances**

Before matching, funds/assets are moved from available balance to locked balance.

```plaintext
lockLimitBalance(input);
```

Implementation:

```typescript
if (input.side === "buy") {
  usdBalance.available -= requiredAmount;
  usdBalance.locked += requiredAmount;
} else {
  assetBalance.available -= input.qty;
  assetBalance.locked += input.qty;
}
```

Why?

```plaintext
Without locking:

Available USD = 1000

Order #1 -> BUY 5 SOL
Order #2 -> BUY 5 SOL

Both orders could spend the same $1000.
```

* * *

**3\. Matching Against the Order Book**

The engine looks at the opposite side of the book.

```typescript
const oppositeSide =
  input.side === "buy"
    ? book.asks
    : book.bids;
```

Then prices are sorted.

```javascript
const prices = [...oppositeSide.keys()].sort(
  (a, b) => input.side === "buy"
    ? a - b
    : b - a
);
```

This guarantees:

```plaintext
BUY  -> cheapest sellers first
SELL -> highest buyers first
```

* * *

**4\. Preventing Self-Trading**

A trader should never match against their own order.

```typescript
if (restingOrder.userId === input.userId) {
  continue;
}
```

Example:

```plaintext
Shubham places:

BUY 5 SOL @ 100

Then:

SELL 5 SOL @ 100

Engine skips the order.
```

* * *

**5\. Partial Fill Support**

The engine calculates how much can actually trade.

```javascript
const matchedQty = Math.min(
  remainingQty,
  availableQty,
);
```

Example:

```plaintext
Incoming BUY = 10 SOL

Available SELL = 3 SOL

Matched = 3 SOL
Remaining = 7 SOL
```

* * *

**6\. Creating Trade Executions (Fills)**

Every successful trade creates a fill.

```javascript
const fill = createFill({
  input,
  orderId,
  restingOrder,
  price,
  matchedQty,
});
```

Implementation:

```javascript
const fill: Fill = {
  fillId: crypto.randomUUID(),
  symbol: input.symbol,
  price,
  qty: matchedQty,
};
```

* * *

7\*\*. Maintaining Trade History\*\*

Every fill is stored globally.

```javascript
FILLS.push(fill);
```

This enables:

*   Recent Trades
    
*   Trade History
    
*   Market Statistics
    
*   Volume Calculation
    

* * *

**8\. Updating Maker Orders**

When a trade occurs, the resting order in orderbook must be updated.

```javascript
updateMakerOrder({
  orderId: restingOrder.orderId,
  matchedQty,
  fill,
});
```

Implementation:

```javascript
makerOrder.filledQty += matchedQty;
makerOrder.fills.push(fill);
```

* * *

**9\. Balance Settlement**

After matching, assets and funds are exchanged.

```typescript
settleLimitTrade({
  input,
  restingOrder,
  matchedQty,
  price,
});
```

Example:

```plaintext
Buyer:
+2 SOL

Seller:
+$200
```

* * *

**10\. Removing Filled Orders**

Completed orders should disappear from the book.

```javascript
removeFilledOrders({
  side: oppositeSide,
  price,
  restingOrders,
});
```

Implementation:

```typescript
side.set(
  price,
  restingOrders.filter(
    (o) => o.status !== "filled"
  )
);
```

* * *

**11\. Average Execution Price**

Orders can execute across multiple price levels.

Example:

```plaintext
2 SOL @ 100
3 SOL @ 110
5 SOL @ 120
```

Calculation:

```javascript
totalTradedValue += matchedQty * price;
totalFilledQty += matchedQty;

averagePrice = totalTradedValue / totalFilledQty;
```

Result:

```plaintext
((2×100)+(3×110)+(5×120))/10 = 113
```

* * *

1**2\. Adding Remaining Quantity to the Order Book**

If the order isn't fully filled, the remaining quantity becomes a maker order.

```javascript
addRestingLimitOrder({
  input,
  orderId,
  remainingQty,
});
```

Implementation:

```javascript
sideMap.set(input.price!, level);
```

Example:

```plaintext
BUY 10 SOL @ 100

Matched = 6
Remaining = 4
```

* * *

### Explore the Complete Source Code

Above we we focused on the core concepts behind a limit order matching engine:

*   Order Creation
    
*   Balance Locking
    
*   Order Matching
    
*   Partial Fills
    
*   Trade Execution (Fills)
    
*   Order Book Management
    
*   Balance Settlement
    
*   Average Price Calculation
    

To keep the article readable, I only included the most important snippets.

If you'd like to explore the complete implementation, including all helper functions, data structures, and detailed inline comments, check out the full project on GitHub.

```plaintext
src/
├── orders/
│   ├── handleCreateOrder.ts
│   ├── handleLimitOrder.ts
│   ├── limit/
│   │   ├── createLimitOrderRecord.ts
│   │   ├── lockLimitBalance.ts
│   │   ├── matchLimitOrder.ts
│   │   ├── settleLimitTrade.ts
│   │   └── addRestingLimitOrder.ts
│   │
│   └── shared/
│       ├── createFill.ts
│       ├── updateMakerOrder.ts
│       └── removeFilledOrders.ts
│
├── store/
│   └── exchange-store.ts
│
└── utils/
    ├── getBalance.ts
    └── getOrderBook.ts
```

GitHub Repository:

[https://github.com/shubhamsinghbundela/centralized-exchange](https://github.com/shubhamsinghbundela/centralized-exchange)

I've added detailed comments throughout the codebase explaining the matching process, balance movements, settlement logic, and order book updates step by step.

* * *

### Step 13: Let's Implement a Market Order Matching Engine from Scratch

Unlike limit orders, market orders do not specify a price.

The goal is simple:

> Execute immediately against the best available liquidity in the order book.

This means a market order can consume multiple price levels until the requested quantity is completely filled or liquidity runs out.

Let's build it step by step.

* * *

**1\. Validating and Locking Balances**

Before matching begins, the exchange must verify that the user has sufficient funds/assets.

```javascript
validateAndLockMarketBalance(input);
```

Implementation:

```javascript
if (input.side === "sell") {
  assetBalance.available -= input.qty;
  assetBalance.locked += input.qty;
}

if (input.side === "buy") {
  validateMarketBuyBalance(input);
}
```

**Why?**

Without locking, the same funds could be spent multiple times before matching completes.

Example:

```js
Available SOL = 10
Order #1 -> SELL 10 SOL
Order #2 -> SELL 10 SOL
```

Both orders could attempt to sell the same assets.

Locking prevents this.

* * *

* * *

**2\. Simulating Market Buy Cost**

Market buy orders are unique because they don't specify a price.

The engine must determine how much USD will be required before execution.

```typescript
validateMarketBuyBalance(input);
```

The engine walks through the ask side of the book.

```typescript
const prices = [...book.asks.keys()].sort(
  (a, b) => a - b,
);
```

Starting from the cheapest ask:

Example:

```js
ORDERS BOOK
SELL 2 SOL @ 100
SELL 3 SOL @ 110
SELL 5 SOL @ 120

Market BUY 10 SOL

Required USD:

(2 × 100) + (3 × 110) + (5 × 120) = 1130 USD
```

If the user doesn't have enough balance, the order is rejected before matching starts.

* * *

**3\. Creating the Market Order Record**

Every market order receives a unique identifier.

```typescript
const order: OrderRecord = {
  orderId,
  userId: input.userId,
  side: input.side,
  type: "market",
  symbol: input.symbol,
  price: null,
  qty: input.qty,
  filledQty: 0,
  status: "open",
  fills: [],
};
```

* * *

**4\. Finding the Opposite Side of the Book**

Market orders always consume existing liquidity.

```typescript
const oppositeSide =
  input.side === "buy"
    ? book.asks
    : book.bids;
```

Meaning:

Market BUY → consumes asks

Market SELL → consumes bids

* * *

**5\. Matching Best Prices First**

Prices are sorted according to exchange priority rules.

```javascript
const prices = [...oppositeSide.keys()].sort(
  (a, b) =>
    input.side === "buy"
      ? a - b
      : b - a,
);
```

This guarantees:

BUY orders get the cheapest sellers first

SELL orders get the highest buyers first

* * *

**6\. Preventing Self-Trading**

A trader should never execute against their own resting order.

```javascript
if (restingOrder.userId === input.userId) {
  continue;
}
```

Example:

Shubham places:

`BUY 5 SOL @ 100`

Then submits:

`Market SELL 5 SOL`

The engine skips his own order.

* * *

**7\. Supporting Partial Fills**

The engine calculates how much quantity can actually trade.

```javascript
const matchedQty = Math.min(
  remainingQty,
  availableQty,
);
```

Example:

Incoming `Market BUY = 10 SOL`

Available `SELL = 3 SOL`

`Matched = 3 SOL`

`Remaining = 7 SOL`

The engine continues searching for liquidity.

* * *

**8\. Creating Trade Executions (Fills)**

Every successful match creates a fill.

```typescript
const fill = createFill({
  input,
  orderId,
  restingOrder,
  price,
  matchedQty,
});
```

Implementation:

```javascript
const fill: Fill = {
  fillId: crypto.randomUUID(),
  symbol: input.symbol,
  price,
  qty: matchedQty,
};
```

Each fill represents an executed trade.

* * *

**9\. Maintaining Trade History**

Every fill is stored globally.

```plaintext
FILLS.push(fill);
```

This powers:

*   Recent Trades
    
*   Trade History
    
*   Market Statistics
    
*   Volume Tracking
    

* * *

**10\. Updating Maker Orders**

The resting order must be updated after every match.

```typescript
updateMakerOrder({
  orderId: restingOrder.orderId,
  matchedQty,
  fill,
});
```

Implementation:

```javascript
makerOrder.filledQty += matchedQty;
makerOrder.fills.push(fill);
```

This ensures accurate order status tracking.

* * *

**11\. Balance Settlement**

After a trade executes, assets and funds are exchanged.

```javascript
settleMarketTrade({
  input,
  restingOrder,
  matchedQty,
  price,
});
```

Example:

Buyer: `+2 SOL`

Seller: `+200 USD`

The exchange updates balances immediately after execution.

* * *

**12\. Removing Filled Orders**

Orders that are completely filled should disappear from the book.

```typescript
removeFilledOrders({
  side: oppositeSide,
  price,
  restingOrders,
});
```

Implementation:

```javascript
side.set(
  price,
  restingOrders.filter(
    (o) => o.status !== "filled"
  )
);
```

This keeps the order book clean.

* * *

**13\. Calculating Average Execution Price**

A market order may execute across multiple price levels.

Example:

```js
2 SOL @ 100
3 SOL @ 110
5 SOL @ 120
```

Calculation:

```javascript
totalTradedValue += matchedQty * price;
totalFilledQty += matchedQty;
```

Average Price:

```typescript
averagePrice =
  totalTradedValue /
  totalFilledQty;
```

Result:

```js
((2×100)+(3×110)+(5×120))/10 = 113
```

This is the final execution price reported to the trader.

* * *

**14\. Handling Unfilled Quantity**

Unlike limit orders, market orders never rest on the order book.

Example:

```js
Market BUY 10 SOL
Available liquidity = 6 SOL

Matched = 6
Remaining = 4
```

The remaining quantity is cancelled.

For sell orders, locked assets are returned.

```typescript
assetBalance.locked -= remainingQty;
assetBalance.available += remainingQty;
```

This ensures users never lose balances for unfilled market orders.

* * *

## Explore the Complete Source Code

Above we focused on the core concepts behind a market order matching engine:

*   Balance Validation
    
*   Fund Locking
    
*   Market Order Execution
    
*   Liquidity Consumption
    
*   Partial Fills
    
*   Trade Execution (Fills)
    
*   Trade History
    
*   Balance Settlement
    
*   Average Execution Price
    
*   Order Book Cleanup
    

To keep the article readable, I only included the most important snippets.

If you'd like to explore the complete implementation, including helper functions, data structures, and detailed inline comments, check out the full project on GitHub.

```plaintext
src/
├── orders/
│   ├── handleMarketOrder.ts
│   │
│   └── market/
│       ├── validateAndLockMarketBalance.ts
│       ├── validateMarketBuyBalance.ts
│       ├── matchMarketOrder.ts
│       └── settleMarketTrade.ts
│
├── orders/shared/
│   ├── createFill.ts
│   ├── updateMakerOrder.ts
│   └── removeFilledOrders.ts
│
├── store/
│   └── exchange-store.ts
│
└── utils/
    ├── getBalance.ts
    └── getOrderBook.ts
```

GitHub Repository:

[https://github.com/shubhamsinghbundela/centralized-exchange](https://github.com/shubhamsinghbundela/centralized-exchange)

I've added detailed comments throughout the codebase explaining market order execution, liquidity consumption, balance settlement, fill generation, and order book updates step by step.

* * *

### Step 14: Testing a Centralized Exchange with Bun Test and Supertest

After implementing the matching engine, order book, market orders, and balance settlement logic, I wanted confidence that new changes wouldn't break existing functionality.

To achieve this, I started writing automated tests using:

*   Bun Test
    
*   Supertest
    

The goal is simple:

Whenever code changes, the exchange should automatically verify that critical workflows still work correctly.

**Why Testing Matters**

Imagine making a small change in the authentication layer.

Without tests:

*   Signup may stop working.
    
*   Signin may break.
    
*   Existing users may no longer authenticate.
    

You may not discover the issue until production.

Automated tests catch these problems immediately.

**Test Structure**

For API testing, I created a dedicated test directory inside the backend.

```plaintext
backend/
└── test/
    └── auth.test.ts
```

The authentication test suite covers:

*   Successful signup
    
*   Duplicate username validation
    
*   Invalid request validation
    
*   Successful signin
    
*   Unknown user signin
    
*   Invalid password signin
    

Repository:

[https://github.com/shubhamsinghbundela/centralized-exchange](https://github.com/shubhamsinghbundela/centralized-exchange)

File:

```plaintext
backend/test/auth.test.ts
```

The tests are written using Bun Test and Supertest, allowing HTTP endpoints to be tested without manually sending requests from Postman.

This gives fast feedback during development and helps prevent regressions whenever authentication logic changes.

* * *

### Testing Exchange APIs

After authentication tests, I started testing exchange-specific APIs.

The first endpoint was deposits.

```plaintext
backend/
└── test/
    ├── auth.test.ts
    └── exchange.test.ts
```

The deposit test suite covers:

*   Successful deposits
    
*   Multiple deposits accumulating balance
    
*   Invalid deposit requests
    
*   Unauthenticated requests
    

* * *

### Step15: Testing the Limit Order Matching Engine

After testing the deposit, I moved on to one of the most important components of the exchange: the limit order matching engine.

**Writing Unit Tests**

To verify that the engine behaved correctly under different market scenarios, I created a dedicated test suite:

```plaintext
engine/tests/limit-order.test.ts 
```

Check out test code in Github:

[https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/engine/tests/limit-order.test.ts](https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/engine/tests/limit-order.test.ts)

I've added detailed test cases covering realistic exchange behavior to ensure the matching engine remains reliable as new features are added.

* * *

### Step16: Testing the Market Order Matching Engine

**Writing Unit Tests**

```plaintext
engine/tests/market-order.test.ts 
```

Check out test code in Github:

[https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/engine/tests/market-order.test.ts](https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/engine/tests/market-order.test.ts)

* * *

### **Step17: Implementing and Testing the Depth API**

After testing limit order matching engine, I worked on the order book depth endpoint.

```javascript
GET /depth/:symbol
```

The goal of this endpoint is to return the current state of the order book.

Example:

```javascript
GET /depth/BTC
```

The request first reaches the backend, where the symbol is validated using Zod.

```ts
export const symbolParamSchema = z.object({
  symbol: z.string().trim().min(1, "symbol is required"),
});
```

Once validated, the backend forwards the request to the matching engine.

```ts
const engineResponse = await sendToEngine(
  "get_depth",
  { symbol }
);
```

Inside the engine, I implemented a dedicated depth service.

```txt
engine/src/depth/getDepth.ts
```

The service performs three important tasks:

*   Reads the current order book
    
*   Aggregates orders at the same price level
    
*   Returns sorted bids and asks
    

```js
import {
  ORDERBOOKS,
  type DepthLevel,
  type DepthResponse,
} from "../store/exchange-store.js";

export function getDepth(symbol: string): DepthResponse {
  const orderBook = ORDERBOOKS.get(symbol);

  if (!orderBook) {
    return {
      symbol,
      bids: [],
      asks: [],
    };
  }

  const bids: DepthLevel[] = [...orderBook.bids.entries()]
    .sort((a, b) => b[0] - a[0])
    .slice(0, 20)
    .map(([price, orders]) => ({
      price,
      qty: orders.reduce(
        (sum, order) => sum + (order.qty - order.filledQty),
        0,
      ),
    }));

  const asks: DepthLevel[] = [...orderBook.asks.entries()]
    .sort((a, b) => a[0] - b[0]) // lowest ask first
    .slice(0, 20)
    .map(([price, orders]) => ({
      price,
      qty: orders.reduce(
        (sum, order) => sum + (order.qty - order.filledQty),
        0,
      ),
    }));

  return {
    symbol,
    bids,
    asks,
  };
}
```

After implementing the engine logic, I wrote unit tests to verify that depth calculations were correct.

```txt
engine/test/getDepth.test.ts
```

**Writing Unit Tests**

[getDepth.test.ts](https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/engine/tests/getDepth.test.ts?utm_source=chatgpt.com)

* * *

### Step 18: Implementing and Testing User Balances

After implementing and testing the depth endpoint, I worked on the balance API.

```typescript
GET /balance
```

The purpose of this endpoint is simple: return the current balances for the authenticated user.

The backend forwards the request to the matching engine using the authenticated user's ID.

```javascript
const engineResponse = await sendToEngine(
  "get_user_balance",
  {
    userId: getUserId(req),
  }
);
```

Inside the engine, I implemented a dedicated balance service.

```javascript
engine/src/balance/getUserBalance.ts
```

The implementation is intentionally simple.

```javascript
import { BALANCES } from "../store/exchange-store";

export function getUserBalance(userId: string) {
  return BALANCES.get(userId) ?? {};
}
```

Since all balance updates occur during deposits, order creation, matching, settlement, and cancellations, the balance service only needs to return the latest state stored in memory.

I wrote unit tests for getUserBalance:

```typescript
engine/tests/getUserBalance.test.ts
```

**Writing Unit Tests**

[https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/engine/tests/getUserBalance.test.ts](https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/engine/tests/getUserBalance.test.ts)

* * *

### Step 19: Implementing and Testing Order Retrieval

After implementing balance tracking, I worked on the order retrieval API.

```javascript
GET /order/:orderId
```

The purpose of this endpoint is to allow users to retrieve the current state of a specific order.

Example:

```javascript
GET /order/094e0d93-0ac0-4e26-a081-6393e49eb82e
```

The backend validates the order ID and forwards the request to the matching engine.

```javascript
const engineResponse = await sendToEngine(
  "get_order",
  {
    userId: getUserId(req),
    orderId,
  }
);
```

Inside the engine, I implemented a dedicated order lookup service.

```javascript
engine/src/order/getOrder.ts
```

```js
import { ORDERS } from "../store/exchange-store";

export function getOrder(userId: string, orderId: string) {
  const order = ORDERS.get(orderId);

  if (!order) {
    throw new Error("Order not found");
  }

  if (order.userId !== userId) {
    throw new Error("Order not found");
  }

  return order;
}
```

**Writing Unit Tests**

```plaintext
engine/tests/getOrder.test.ts
```

GitHub Repository:

[https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/engine/tests/getOrder.test.ts](https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/engine/tests/getOrder.test.ts)

* * *

### Step 20: Implementing and Testing Order Cancellation

After implementing order retrieval, I worked on order cancellation.

The endpoint:

```plaintext
DELETE /order/:orderId
```

allows traders to cancel open or partially filled orders.

Example:

```plaintext
DELETE /order/094e0d93-0ac0-4e26-a081-6393e49eb82e
```

The backend validates the order ID and forwards the request to the matching engine.

```typescript
const engineResponse = await sendToEngine(
  "cancel_order",
  {
    userId: getUserId(req),
    orderId,
  }
);
```

Inside the engine, I implemented a dedicated cancellation service.

```plaintext
engine/src/orders/cancelOrder.ts
```

```js
import { BALANCES, ORDERBOOKS, ORDERS } from "../store/exchange-store";

export function cancelOrder(userId: string, orderId: string) {
  const order = ORDERS.get(orderId);

  if (!order) {
    throw new Error("order not found");
  }

  if (order.userId !== userId) {
    throw new Error("order not found");
  }

  if (order.status === "filled") {
    throw new Error("filled orders cannot be cancelled");
  }

  if (order.status === "cancelled") {
    throw new Error("order already cancelled");
  }

  // remove from orderbook
  const orderBook = ORDERBOOKS.get(order.symbol);

  const levels = order.side === "buy" ? orderBook?.bids : orderBook?.asks;

  const priceLevel = levels?.get(order.price!);

  if (priceLevel) {
    const updated = priceLevel.filter((o) => o.orderId !== orderId);

    if (updated.length === 0) {
      levels?.delete(order.price!);
    } else {
      levels?.set(order.price!, updated);
    }
  }
  // unlock balances
  const remainingQty = order.qty - order.filledQty;
  const balances = BALANCES.get(userId)!;

  if (order.side === "buy") {
    const refund = remainingQty * order.price!;

    balances.USD!.locked -= refund;
    balances.USD!.available += refund;
  } else {
    const assetBalance = balances[order.symbol];

    if (!assetBalance) {
      throw new Error(`${order.symbol} balance not found`);
    }

    assetBalance.locked -= remainingQty;
    assetBalance.available += remainingQty;
  }

  order.status = "cancelled";

  return {
    orderId,
    status: "cancelled",
    qty: order.qty,
    filledQty: order.filledQty,
  };
}
```

* * *

**Validation Rules**

Before cancelling an order, the engine performs several checks.

The order must:

*   Exist
    
*   Belong to the requesting user
    
*   Not already be filled
    
*   Not already be cancelled
    

* * *

**Removing Orders From The Book**

When an order is cancelled, it should no longer appear in market depth.

The engine removes the order from its price level.

```plaintext
const updated = priceLevel.filter(
  (o) => o.orderId !== orderId
);
```

If no orders remain at that price level, the level itself is removed.

* * *

**Unlocking Funds**

One of the most important parts of cancellation is releasing locked balances.

For buy orders:

```plaintext
Locked USD → Available USD
```

Example:

```plaintext
BUY 10 BTC @ 100

Locked USD = 1000
```

After cancellation:

```plaintext
Locked USD = 0
Available USD restored
```

For sell orders:

```plaintext
Locked BTC → Available BTC
```

Example:

```plaintext
SELL 10 BTC
```

After cancellation:

```plaintext
Locked BTC = 0
Available BTC restored
```

* * *

**Partial Fill Handling**

Cancellation must only release the remaining unfilled quantity.

Example:

```plaintext
BUY 10 BTC @ 100
```

After:

```plaintext
Filled = 4 BTC
Remaining = 6 BTC
```

When cancelled:

```plaintext
Only 6 BTC worth of funds
are unlocked.
```

The executed portion remains part of trade history.

* * *

**Writing Unit Tests**

To validate the implementation, I written test in below file:

```plaintext
engine/tests/cancelOrder.test.ts
```

GitHub Repository:

[https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/engine/tests/cancelOrder.test.ts](https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/engine/tests/cancelOrder.test.ts)

* * *

### Step 21: Understanding Latency Before Load Testing

After completing all exchange functionality (deposits, withdrawals, order placement,matching, orderbook updates, fills, order retrieval, and order cancellation), I wanted to understand how fast the system actually was.

Before running any load tests, I spent some time learning what latency mean?

* * *

**What is Latency?**

Latency is the total time between sending a request and receiving a response.

When a browser requests a webpage, several steps happen:

1.  DNS lookup
    
2.  TCP handshake
    
3.  TLS handshake (HTTPS)
    
4.  Request sent to server
    
5.  Server processing
    
6.  Response returned to client
    

Each step adds delay.

Example:

| Step | Time |
| --- | --- |
| DNS lookup | 20 ms |
| TCP handshake | 30 ms |
| TLS handshake | 50 ms |
| Server processing | 100 ms |
| Response travel back | 30 ms |
| **Total latency** | **230 ms** |

The user experiences the entire 230 ms as waiting time.

* * *

**Why Latency Matters**

Modern applications rarely make a single request.

A webpage might need:

*   1 HTML file
    
*   5 CSS files
    
*   10 JavaScript files
    
*   20 images
    

Total = 36 requests

If every request has 100 ms latency:

```plaintext
36 × 100 ms = 3600 ms
```

or roughly **3.6 seconds** of waiting spread throughout page loading.

Even small latency improvements can noticeably improve user experience.

* * *

**Types of Latency**

**Network Latency**

Time spent moving data across the network.

```plaintext
Browser
   ↓
Internet
   ↓
Server
```

Example:

*   50 ms to reach server
    
*   50 ms to return
    

Network latency ≈ 100 ms round trip.

* * *

**Server / Disk Latency**

Time spent inside the server before generating a response.

Example:

```plaintext
Request arrives
      ↓
Database read
      ↓
Business logic
      ↓
Response generated
      ↓
Response sent
```

If a database query takes 300 ms, that delay is server-side latency.

Since my exchange keeps order books and balances in memory, I expected server-side latency to remain relatively low compared to systems heavily dependent on database reads.

* * *

**Types of Latency**

**Network Latency**

Time spent moving data across the network.

```plaintext
Browser
   ↓
Internet
   ↓
Server
```

Example:

*   50 ms to reach server
    
*   50 ms to return
    

Network latency ≈ 100 ms round trip.

**Server / Disk Latency**

Time spent inside the server before generating a response.

Example:

```plaintext
Request arrives
      ↓
Database read
      ↓
Business logic
      ↓
Response generated
      ↓
Response sent
```

If a database query takes 300 ms, that delay is server-side latency.

Since my exchange keeps order books and balances in memory, I expected server-side latency to remain relatively low compared to systems heavily dependent on database reads.

* * *

### Step 22: Learning k6 for Load Testing

Once I understood latency, I started learning **k6**, an open-source load testing tool.

The goal was to simulate real users interacting with the exchange and measure:

*   API latency
    
*   Throughput
    
*   Failure rates
    
*   System behavior under concurrent load
    

* * *

**Important k6 Concepts**

**Virtual Users (VU)**

A Virtual User simulates a real user.

Example:

```plaintext
vus: 100
```

means 100 concurrent users are making requests.

* * *

**Iteration**

One complete execution of the `default()` function.

```plaintext
1 iteration = one full user workflow
```

* * *

**HTTP Metrics**

k6 automatically reports metrics such as:

```plaintext
http_req_duration:
avg=189ms
min=182ms
max=472ms
p(90)=189ms
p(95)=191ms
```

Meaning:

*   avg → average response time
    
*   min → fastest request
    
*   max → slowest request
    
*   p90 → 90% of requests completed below this value
    
*   p95 → 95% of requests completed below this value
    

`http_req_duration` is essentially the API latency observed by the client.

* * *

**Failed Requests**

```plaintext
http_req_failed
```

Shows the percentage of failed requests.

* * *

**Using check()**

Without checks:

```plaintext
http.post(...)
```

k6 only knows that a request was sent.

With checks:

```typescript
check(res, {
  "status is 200": (r) => r.status === 200,
});
```

k6 can verify correctness of responses.

* * *

**Understanding stdout**

Everything printed in the terminal is called stdout.

Useful flags:

```plaintext
k6 run --quiet test.js
```

Hides execution details and progress bars.

```plaintext
k6 run --log-output=none test.js
```

Disables logs.

```plaintext
k6 run --quiet --log-output=none test.js
```

Shows only the final summary.

* * *

### Step 23: Seeding 100 Test Users

Before performing load tests, I needed users with balances.

I generated JWT tokens for 100 unique users and used a k6 setup script to seed balances.

Each user received:

```plaintext
USD = 1,000,000
BTC = 1,000
```

The setup function runs once before the actual test and prepares the environment.

This step was not load testing itself—it was test data preparation.

Code: [https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/load-tests/setup.js](https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/load-tests/setup.js)

* * *

### Step 24: Simulating Exchange Traffic

To mimic a real exchange, I created three different user groups.

**Makers (30 VUs)**

Makers place limit orders that provide liquidity.

```plaintext
30 concurrent users
```

Actions:

*   Place BUY limit orders
    
*   Place SELL limit orders
    

Example:

```plaintext
type: "limit"
```

* * *

**Takers (30 VUs)**

Takers consume liquidity by submitting market orders.

```plaintext
30 concurrent users
```

Actions:

*   Market BUY
    
*   Market SELL
    

Example:

```plaintext
type: "market"
```

* * *

**Depth Readers (40 VUs)**

Many traders continuously monitor market depth.

```plaintext
40 concurrent users
```

Actions:

```plaintext
GET /depth/BTC
```

This simulates users refreshing the orderbook view.

* * *

**Test Distribution**

| User Type | VUs |
| --- | --- |
| Makers | 30 |
| Takers | 30 |
| Depth Readers | 40 |
| Total | 100 |

The test generated continuous order placement, matching, and orderbook reads simultaneously, helping evaluate the exchange under concurrent trading activity.

**Code:** [https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/load-tests/cex-benchmark.js](https://github.com/shubhamsinghbundela/centralized-exchange/blob/main/load-tests/cex-benchmark.js)

**k6 benchmark results under 100 concurrent users (30 makers, 30 takers, 40 depth readers**

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/91652766-fb6e-4cf5-a97c-58f812b9ef74.png align="center")

* * *

### Step 25: Understanding Redis Persistence and Durability

After benchmarking the exchange under load, I started thinking about a different problem:

> What happens if the matching engine crashes?

At that point, the exchange state was stored entirely in memory:

*   BALANCES
    
*   ORDERS
    
*   ORDERBOOKS
    
*   FILLS
    

A process restart would wipe everything.

To understand how production systems handle this, I studied Redis persistence using the Redis tutorial on persistence and durability.

[https://redis.io/tutorials/operate/redis-at-scale/persistence-and-durability/](https://redis.io/tutorials/operate/redis-at-scale/persistence-and-durability/)

**RDB Snapshots**

RDB works like taking periodic photos of the database.

Imagine Redis takes snapshots of the current state:

```plaintext
Time 0s   → Snapshot 
Time 20s  → Snapshot 
Time 40s  → Snapshot 
```

Example configuration:

```plaintext
save 20 3
```

Meaning:

*   At least 3 keys changed
    
*   Within 20 seconds
    

Redis creates a snapshot file:

```plaintext
dump.rdb
```

The snapshot contains the entire dataset at that moment.

**Limitation of RDB**

Suppose:

```plaintext
12:00 Snapshot created
12:01 SET d 4
12:02 SET e 5
12:03 Server crashes 
```

The writes after the last snapshot are lost.

```plaintext
d = 4
e = 5
```

This is the trade-off of snapshot-based persistence.

* * *

**AOF (Append Only File)**

AOF works differently.

Instead of taking photos, Redis records every write operation.

Example:

```plaintext
SET a 1
SET b 2
SET c 3
SET d 4
```

During restart:

```plaintext
Start Redis
      ↓
Read AOF
      ↓
Replay Commands
      ↓
Restore Dataset
```

AOF provides better durability but produces larger files and slower recovery compared to RDB.

* * *

### Step 26: Implementing Exchange State Snapshots

My matching engine stores data in JavaScript Maps.

```plaintext
BALANCES
ORDERS
ORDERBOOKS
FILLS
```

Redis can only persist Redis keys, not in-memory JavaScript structures.

To solve this, I implemented a snapshot layer that serializes the exchange state and stores it in Redis.

**Persisting State**

I created a persistence module:

```plaintext
persistEngineState()
```

which stores:

```plaintext
engine:balances
engine:orders
engine:orderbooks
engine:fills
```

inside Redis.

Example:

```plaintext
await redis.set(
  "engine:balances",
  JSON.stringify(Object.fromEntries(BALANCES))
);
```

Full Code:

```js
src/snapshot/persistence.js
```

```js
import {
  BALANCES,
  ORDERBOOKS,
  ORDERS,
  FILLS,
} from "../store/exchange-store.js";
import { createClient } from "redis";
import { env } from "../utils/env.js";

const redis = createClient({
  url: env.redisUrl,
});

await redis.connect();
export async function persistEngineState() {
  await redis.set(
    "engine:balances",
    JSON.stringify(Object.fromEntries(BALANCES)),
  );

  await redis.set("engine:orders", JSON.stringify([...ORDERS.entries()]));

  await redis.set(
    "engine:orderbooks",
    JSON.stringify(
      [...ORDERBOOKS.entries()].map(([symbol, book]) => ({
        symbol,
        bids: [...book.bids.entries()],
        asks: [...book.asks.entries()],
      })),
    ),
  );

  await redis.set("engine:fills", JSON.stringify(FILLS));
}
```

* * *

### Step 27: Automatic Snapshot Creation

I wanted snapshots to happen automatically.

A mutation counter tracks state-changing operations:

```plaintext
let mutationCount = 0;
```

Every:

*   Deposit
    
*   Create Order
    
*   Cancel Order
    

increments the counter.

Every 5 mutations:

```plaintext
await persistEngineState();

await brokerClient.sendCommand(["BGSAVE"]);
```

This performs:

```plaintext
Engine State
      ↓
Redis Keys
      ↓
BGSAVE
      ↓
dump.rdb
```

without blocking the matching engine.

**Full Code**:

```js
src/index.ts
```

```js
let mutationCount = 0;

async function snapshotIfNeeded() {
  mutationCount++;

  if (mutationCount % 5 !== 0) return;

  console.log("Persisting engine state...");

  await persistEngineState();

  try {
    await brokerClient.sendCommand(["BGSAVE"]);
    console.log("Redis background snapshot started.");
  } catch (error) {
    if (
      error instanceof Error &&
      error.message.includes("Background save already in progress")
    ) {
      console.log(
        "Redis snapshot already in progress. Engine state is saved in Redis memory; skipping this snapshot.",
      );
      return;
    }

    throw error;
  }
}

async function handleEngineRequest(message: EngineRequest): Promise<unknown> {
 
  if (message.type === "create_order") {
    const result = handleCreateOrder(message.payload);

    await snapshotIfNeeded();

    return result;
  }

  if (message.type === "deposit") {
    const result = handleDeposit(message.payload);

    await snapshotIfNeeded();

    return result;
  }

  if (message.type === "cancel_order") {
    const { userId, orderId } = message.payload as {
      userId: string;
      orderId: string;
    };

    const result = cancelOrder(userId, orderId);

    await snapshotIfNeeded();

    return result;
  }

  throw new Error("TODO(student): implement this engine request type");
}
```

* * *

## Step 28: Restoring State After Restart

Persisting data is only half the problem.

The exchange also needs to recover after a restart.

For this I implemented:

```plaintext
loadEngineState()
```

When the engine starts:

```plaintext
await loadEngineState();
```

The function reads:

```plaintext
engine:balances
engine:orders
engine:orderbooks
engine:fills
```

from Redis and reconstructs:

```plaintext
BALANCES
ORDERS
ORDERBOOKS
FILLS
```

back into memory.

For order books, nested Maps are rebuilt:

```javascript
ORDERBOOKS.set(book.symbol, {
  bids: new Map(book.bids),
  asks: new Map(book.asks),
});
```

This allows the matching engine to resume from the previous state.

* * *

### Step 29: Dockerizing Redis Persistence

To make the setup reproducible, Redis was containerized using Docker Compose.

```js
services:
  redis:
    image: redis/redis-stack:latest
    container_name: redis-stack
    ports:
      - "6379:6379"
      - "8001:8001"
    volumes:
      - redis-data:/data
    command: >
      redis-server
      --save 60 5
      --dbfilename dump.rdb

volumes:
  redis-data:
```

Using a Docker volume ensures that Redis snapshots survive container restarts.

* * *

**Persistence Flow**

```typescript
BALANCES
ORDERS
ORDERBOOKS
FILLS
        ↓
persistEngineState()
        ↓
Redis Keys
        ↓
BGSAVE
        ↓
dump.rdb
        ↓
Docker Volume
```

Recovery Flow:

```javascript
Redis Restart
        ↓
Load dump.rdb
        ↓
loadEngineState()
        ↓
Rebuild Exchange State
        ↓
Trading Resumes
```

* * *

**Key Learnings**

*   Learned the difference between RDB and AOF persistence.
    
*   Understood durability vs performance trade-offs.
    
*   Implemented exchange state persistence using Redis.
    
*   Added automatic snapshots after mutations.
    
*   Implemented recovery logic for balances, orders, fills, and order books.
    
*   Used Docker volumes to preserve snapshots across container restarts.
    
*   Verified state restoration after restarting the engine.
    

Next step: Export Redis snapshots to AWS S3 and build a disaster recovery workflow.

* * *

I shared a quick overview of the approach and implementation related to redis persistance on X.

**Tweet:** [https://x.com/shubhamsingh\_\_1/status/2070360494565941303](https://x.com/shubhamsingh__1/status/2070360494565941303)

* * *

### Step 30: Eliminating Floating-Point Precision Errors with Decimal.js

After implementing Redis persistence and recovery, I discovered another issue that is critical for any financial application: **floating-point precision**.

JavaScript stores numbers using the IEEE-754 floating-point standard, which cannot represent many decimal values exactly.

For example:

```plaintext
0.1 + 0.2
// 0.30000000000000004
```

Although the error appears very small, it becomes a serious problem inside a trading engine where every calculation affects user balances.

Operations such as:

*   Deposits
    
*   Balance settlement
    
*   Order matching
    

must be mathematically exact.

Even tiny rounding errors can accumulate over thousands or millions of trades and eventually produce incorrect balances.

* * *

**Moving to Decimal.js**

To eliminate precision issues, I replaced JavaScript's native `number` type with **Decimal.js** for all financial values.

The balance model was updated from:

```javascript
export interface Balance {
  available: number;
  locked: number;
}
```

to:

```typescript
import Decimal from "decimal.js";

export interface Balance {
  available: Decimal;
  locked: Decimal;
}
```

Every balance calculation now uses Decimal arithmetic instead of native operators.

Example:

```typescript
const price = new Decimal("110.15");
const quantity = new Decimal("0.25");

const value = price.times(quantity);

console.log(value.toString());
// 27.5375
```

Instead of:

```plaintext
110.15 * 0.25
// 27.537499999999998
```

* * *

**Engine Changes**

Migrating to Decimal.js required updating the entire matching engine.

Financial calculations now use Decimal methods such as:

*   `.plus()`
    
*   `.minus()`
    
*   `.times()`
    
*   `.div()`
    
*   `.greaterThan()`
    
*   `.lessThan()`
    
*   `.equals()`
    

instead of JavaScript arithmetic operators.

This guarantees that balances, prices, quantities, and settlements remain precise throughout the trading lifecycle.

* * *

**Why This Matters**

Financial software cannot tolerate approximation.

Unlike many web applications, an exchange processes thousands of monetary transactions where even microscopic rounding errors can result in incorrect balances.

Using Decimal.js ensures deterministic, precise, and predictable calculations throughout the engine.

* * *

**Further Reading**

I also wrote a detailed thread explaining why JavaScript floating-point numbers are unsafe for financial applications and how migrating to `decimal.js` eliminates precision errors in a matching engine.

If you're interested, you can read it here:

[https://x.com/shubhamsingh\_\_1/status/2069820565435863238?s=20](https://x.com/shubhamsingh__1/status/2069820565435863238?s=20)

[https://x.com/shubhamsingh\_\_1/status/2069807991466111009?s=20](https://x.com/shubhamsingh__1/status/2069807991466111009?s=20)

* * *

### Step 31: Designing Data Persistence Without Slowing Down the Matching Engine

After implementing Redis snapshots and engine recovery, the next challenge was deciding **how to persist executed trades, orders, and balances into PostgreSQL**.

At first, the obvious solution seemed straightforward:

```plaintext
Order Arrives
      ↓
Matching Engine
      ↓
Update Memory
      ↓
Write to PostgreSQL
```

But this raised an important question.

Should the matching engine write directly to the database every time an order is matched?

The answer is **no**.

A matching engine has one primary responsibility:

> **Match orders as fast as possible.**

Database writes are relatively slow compared to in-memory operations.

If every order, balance update, and trade execution waited for PostgreSQL, the engine's throughput would decrease significantly.

* * *

**Understanding the Separation of Responsibilities**

After discussing the architecture and researching how trading systems are designed, I realized that the matching engine should remain completely focused on matching orders.

During market hours:

*   All balances remain in memory.
    
*   Orders remain in memory.
    
*   Fills remain in memory.
    
*   Redis snapshots provide recovery if the engine crashes.
    

The architecture becomes:

```plaintext
Orders
      ↓
Matching Engine (Memory)
      ↓
Redis Snapshot
```

The engine never waits for PostgreSQL.

* * *

**Where Does PostgreSQL Fit?**

Initially, I was confused.

If Redis already stores the latest state and the frontend can read live data directly from Redis, what is PostgreSQL actually used for?

The answer is:

**Long-term persistence.**

PostgreSQL is not part of the critical trading path.

Instead, it stores historical data that must survive beyond the engine's runtime.

Examples include:

*   Historical orders
    
*   Executed trades
    
*   Completed fills
    
*   Account history
    
*   Reports and analytics
    

* * *

**Why Not Write During Trading?**

While the market is open:

*   Thousands of orders may arrive every second.
    
*   The matching engine must respond with the lowest possible latency.
    
*   Even a small delay caused by database writes can affect performance.
    

Therefore, speed takes priority over persistence during live trading.

* * *

**Bulk Synchronization After Market Close**

Once the market closes:

*   No new orders are arriving.
    
*   The final state of every order is already available in memory.
    
*   Redis contains the latest snapshot.
    

At this point, the complete market state can be synchronized to PostgreSQL in bulk.

```plaintext
Market Closes
      ↓
Read Final State
      ↓
Bulk Sync
      ↓
PostgreSQL
```

This approach keeps the matching engine fast while still maintaining permanent historical records.

* * *

**Live UI During Market Hours**

One question I had was how users would see newly placed orders immediately if PostgreSQL wasn't being updated continuously.

The solution is straightforward:

*   Live open orders are served directly from Redis (or the matching engine).
    
*   Real-time updates are delivered using WebSockets.
    
*   PostgreSQL is used only for historical records after synchronization.
    

This ensures users receive instant updates without introducing database latency into the matching engine.

* * *

**Further Reading**

While designing the persistence layer for my centralized exchange, I shared my thought process and discussed different architectural approaches with the community on X.

If you're interested in the complete discussion, you can read it here:  
[https://x.com/shubhamsingh\_\_1/status/2070154350144655466](https://x.com/shubhamsingh__1/status/2070154350144655466)

* * *

### Step 32: Market Close Synchronization & Database Ownership

Before implementing the synchronization logic, I learned an important microservices principle:

> **Each microservice should own its own database.**

Initially, both my **Backend** and **Matching Engine** shared the same PostgreSQL database.

At first, it seemed like the simplest approach. However, once I started implementing persistence for the matching engine, several problems became obvious.

The matching engine maintains the complete order book in memory throughout market hours. When the market closes, it synchronizes the final state of **Orders** and **Fills** to PostgreSQL.

Sharing the same database introduced several issues:

*   Prisma migrations started conflicting.
    
*   The matching engine became dependent on tables it didn't own.
    
*   Schema changes in one service could unintentionally impact another service.
    

The solution was to give each service complete ownership of its own data.

```plaintext
Backend Database
└── Users
```

```js
Matching Engine Database 
├── Orders 
├── Fills
```

Now the architecture is much cleaner.

The backend authenticates users and forwards requests (including the authenticated `userId`) to the matching engine.

The engine owns everything related to:

*   Order matching
    
*   Order books
    
*   Trade execution
    
*   Orders
    
*   Fills
    
*   Market persistence
    

A simple rule that significantly improved the architecture:

> **A service should own both its business logic and the data behind that logic.**

* * *

**Designing the Engine Database**

Once the matching engine had its own PostgreSQL database, the next step was defining the schema.

Unlike the backend database, which stores users and authentication data, the engine database only stores trading-related information.

The two core entities are:

*   **Orders** — every order placed in the exchange.
    
*   **Fills** — every trade generated when two orders are matched.
    

The relationship is straightforward:

```plaintext
Order (Buyer)   ────
                      ├──── Fill
Order (Seller)  ────┘
```

A single order can participate in multiple trades, so an `Order` has a one-to-many relationship with `Fill`.

The Prisma schema looks like this:

```js
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

// Get a free hosted Postgres database in seconds: `npx create-db`

generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model Order {
  orderId String @id

  userId String
  side   String
  type   String
  symbol String
  price  Decimal? 
  qty    Decimal
  filledQty Decimal
  status String
  createdAt DateTime

  buyFills  Fill[] @relation("BuyOrder")
  sellFills Fill[] @relation("SellOrder")
}

model Fill {
  fillId String @id

  symbol String
  price  Decimal
  qty    Decimal

  buyOrderId  String
  sellOrderId String

  createdAt DateTime

  buyOrder  Order @relation("BuyOrder", fields: [buyOrderId], references: [orderId])
  sellOrder Order @relation("SellOrder", fields: [sellOrderId], references: [orderId])
}
```

* * *

**Closing the Market**

To simulate market trading hours, I introduced a simple market state.

Every incoming order first checks whether the market is currently open.

```javascript
export function isMarketOpen() {
  updateMarketState();
  return marketOpen;
}
```

Inside the order handler:

```javascript
if (!isMarketOpen()) {
  throw new Error("Market is closed");
}
```

This prevents any new orders from entering the matching engine once trading hours have ended.

* * *

**Automatically Running Market Close**

Instead of manually triggering synchronization, I scheduled it using **node-cron**.

```javascript
cron.schedule(
  env.marketCloseTime,
  async () => {
    await handleMarketClose();
  },
  {
    timezone: env.marketTimezone,
  },
);
```

The schedule is configurable through environment variables.

```javascript
MARKET_CLOSE_CRON=30 15 * * 1-5
MARKET_TIMEZONE=Asia/Kolkata
```

Every weekday at **3:30 PM IST**, the engine automatically starts the synchronization process.

* * *

**Synchronizing Orders and Fills**

When the market closes, the engine performs a bulk synchronization.

```plaintext
Market Closes
      ↓
Read Orders
Read Fills
      ↓
Bulk Insert
      ↓
PostgreSQL
```

The synchronization entry point is straightforward.

```typescript
export async function handleMarketClose() {
  await syncOrders(prisma);
  await syncFills(prisma);
}
```

Rather than writing to the database after every trade, the engine exports the complete in-memory state once trading has finished.

* * *

**Bulk Writing Orders**

The engine reads every order stored in memory.

```javascript
const orders = [...ORDERS.values()];
```

Instead of inserting one row at a time, all orders are written using `createMany()`.

```javascript
await tx.order.createMany({
  data: ...,
  skipDuplicates: true,
});
```

Bulk inserts dramatically reduce the number of database operations and improve synchronization performance.

* * *

**Bulk Writing Trade Fills**

Trade fills follow the same pattern.

Since a fill could accidentally appear more than once, duplicate entries are removed before insertion.

```typescript
const uniqueFills = [
  ...new Map(FILLS.map(fill => [fill.fillId, fill])).values(),
];
```

The resulting list is then persisted using another bulk insert.

```javascript
await tx.fill.createMany({
  data: ...,
  skipDuplicates: true,
});
```

This guarantees that each trade is stored only once.

* * *

**Final Synchronization Flow**

```plaintext
Market Open
      ↓
Matching Engine (Memory)
      ↓
Redis Snapshots
      ↓
Market Closes
      ↓
Read Orders & Fills
      ↓
Bulk Sync
      ↓
PostgreSQL
```

Throughout trading hours, the matching engine remains focused solely on low-latency order matching.

Only after trading ends does it persist historical data to PostgreSQL.

* * *

**Further Reading**

While designing the persistence layer and deciding how the matching engine should synchronize data with PostgreSQL, I shared my thought process and architectural decisions on X.

You can read the discussion here:

[https://x.com/shubhamsingh\_\_1/status/2070242573759955431](https://x.com/shubhamsingh__1/status/2070242573759955431)

* * *

### Step 33: Learning Real-Time Communication with WebSockets

After implementing market persistence and synchronization, the next challenge was enabling **real-time communication** between the matching engine and clients.

A centralized exchange isn't useful if users have to refresh the page to see order book updates or trades. Every new order, cancellation, and trade should be pushed instantly to connected clients.

Before implementing this, I wanted to understand the different approaches for building real-time systems.

* * *

**Learning** [**Socket.IO**](http://Socket.IO)

Before building the WebSocket layer for the exchange, I spent some time learning how [**Socket.IO**](http://Socket.IO) works and documenting the concepts I found most useful.

I shared my learning notes on X before diving into the implementation:

[**https://x.com/shubhamsingh\_\_1/status/2069414064863019046?s=20**](https://x.com/shubhamsingh__1/status/2069414064863019046?s=20)

* * *

**Understanding** `io` **vs** `socket`

While learning Socket.IO, one concept that initially confused me was the difference between `io` and `socket`.

I documented my notes and examples on X before applying them in the project:

[**https://x.com/shubhamsingh\_\_1/status/2069411978410615105?s=20**](https://x.com/shubhamsingh__1/status/2069411978410615105?s=20)

* * *

**Learning Raw WebSockets (**`ws`**)**

After understanding [Socket.IO](http://Socket.IO), I wanted to learn the WebSocket protocol itself by working with the `ws` library, a lightweight and widely used WebSocket implementation for Node.js.

I documented the key concepts and takeaways on X before integrating WebSockets into my exchange:

[**https://x.com/shubhamsingh\_\_1/status/2070573810387153203?s=20**](https://x.com/shubhamsingh__1/status/2070573810387153203?s=20)

* * *

[**Socket.IO**](http://Socket.IO) **vs Raw WebSockets**

After experimenting with both [**Socket.IO**](http://Socket.IO) and **raw WebSockets (**`ws`**)**, I compared their trade-offs to decide which one would be a better fit for my centralized exchange project.

I shared my comparison and reasoning on X before integrating the WebSocket layer into the exchange:

[**https://x.com/shubhamsingh\_\_1/status/2070575799854633235?s=20**](https://x.com/shubhamsingh__1/status/2070575799854633235?s=20)

* * *

**Why I Chose Raw WebSockets**

After comparing [**Socket.IO**](http://Socket.IO) and **raw WebSockets (**`ws`**)**, I decided to use **raw WebSockets** for my centralized exchange project because it gives me lower protocol overhead and complete control over the communication protocol—both of which are important for latency-sensitive systems like trading engines.

I shared the reasoning behind this architectural decision on X:

[**https://x.com/shubhamsingh\_\_1/status/2070577047009972618?s=20**](https://x.com/shubhamsingh__1/status/2070577047009972618?s=20)

* * *

**Proof of Concept**

Before integrating WebSockets into the exchange, I built a small proof-of-concept to understand client-server communication.

**GitHub Repository**

[https://github.com/shubhamsinghbundela/websocket-poc/tree/main](https://github.com/shubhamsinghbundela/websocket-poc/tree/main)

* * *

**Designing the WebSocket Architecture**

After deciding to use **raw WebSockets (**`ws`**)**, the next question wasn't *how to write WebSocket code*—it was **how the architecture should work**.

My matching engine was already processing orders through a Redis Queue (`LPUSH` + `BRPOP`). But broadcasting real-time order book updates is a different problem.

Before writing any code, I spent some time thinking through the architecture, discussing it with Others, and documenting my understanding.

I shared my thought process on X:

[**https://x.com/shubhamsingh\_\_1/status/2071182638011060734?s=20**](https://x.com/shubhamsingh__1/status/2071182638011060734?s=20)

* * *

### Step 34: Preparing the Matching Engine for Real-Time Depth Updates

Before implementing WebSocket broadcasting, I first needed to make sure the matching engine could expose the order book in a format suitable for real-time updates.

Rather than designing my own response format, I decided to follow the format used by **Backpack Exchange**. Using an existing API structure makes it easier for clients to consume market data and keeps the interface closer to what production exchanges expose.

The `getDepth()` API was updated to return:

*   Trading symbol
    
*   Bid levels
    
*   Ask levels
    
*   `lastUpdateId`
    

The response now looks like this:

```plaintext
{
  symbol: "BTCUSDT",
  bids: [
    ["101.5", "2.4"],
    ["101.4", "1.1"]
  ],
  asks: [
    ["101.6", "3.2"],
    ["101.7", "0.8"]
  ],
  lastUpdateId: 42
}
```

```js
import {
  ENGINE_STATE,
  ORDERBOOKS,
  type DepthLevel,
  type DepthResponse,
} from "../store/exchange-store.js";

export function getDepth(symbol: string): DepthResponse {
  const orderBook = ORDERBOOKS.get(symbol);

  if (!orderBook) {
    return {
      symbol,
      bids: [],
      asks: [],
      lastUpdateId: ENGINE_STATE.lastUpdateId,
    };
  }

  const bids: DepthLevel[] = [...orderBook.bids.entries()]
    .sort((a, b) => b[0] - a[0])
    .map(([price, orders]) => [
      price.toString(),
      orders
        .reduce((sum, order) => sum + (order.qty - order.filledQty), 0)
        .toString(),
    ]);

  const asks: DepthLevel[] = [...orderBook.asks.entries()]
    .sort((a, b) => a[0] - b[0]) // lowest ask first
    .map(([price, orders]) => [
      price.toString(),
      orders
        .reduce((sum, order) => sum + (order.qty - order.filledQty), 0)
        .toString(),
    ]);

  return {
    symbol,
    bids,
    asks,
    lastUpdateId: ENGINE_STATE.lastUpdateId,
  };
}
```

Introducing `lastUpdateId` was especially important because it lays the foundation for streaming incremental order book updates over WebSockets.

Instead of sending the complete order book after every trade, clients can later use this update ID to apply **depth deltas** in the correct order and keep their local order book synchronized with the matching engine.

This was the first step toward implementing efficient real-time market data streaming.

* * *

### Step 35: Building Incremental Order Book Updates (Depth Deltas)

Returning the complete order book through the `getDepth()` API works well when a client first loads the exchange.

However, sending the **entire order book after every order** would quickly become inefficient.

Imagine thousands of orders arriving every second. Broadcasting the full order book each time would waste bandwidth and force clients to repeatedly process data that hadn't changed.

Instead, I decided to send **only the price levels that were modified** by each order.

This is commonly known as a **depth delta**.

**Tracking Changed Price Levels**

During order matching, I introduced a new data structure:

```typescript
const depthDelta: DepthDelta = {
  bids: new Set(),
  asks: new Set(),
};
```

As orders are matched, cancelled, or added to the order book, every affected bid or ask price is recorded in one of these sets.

Instead of scanning the entire order book later, the engine already knows exactly which price levels changed while processing the order.

* * *

**Building the Depth Update**

After the order has been fully processed, the engine generates a depth update.

```typescript
const depthUpdate = buildDepthUpdate(
  input.symbol,
  depthDelta,
);
```

The `buildDepthUpdate()` function iterates only over the modified bid and ask levels.

For each updated price level it calculates the remaining quantity by summing the unfilled quantity of every resting order.

```typescript
const qty = orders.reduce(
  (sum, order) => sum + (order.qty - order.filledQty),
  0,
);
```

The resulting payload contains only the information clients actually need.

```plaintext
{
  s: "BTCUSDT",
  b: [["101.5", "2.4"]],
  a: [["101.6", "1.8"]],
  U: 42,
  u: 42,
  T: ...
}
```

Where:

*   `s` → Trading symbol
    
*   `b` → Updated bid price levels
    
*   `a` → Updated ask price levels
    
*   `U` → First update ID
    
*   `u` → Last update ID
    
*   `T` → Event timestamp
    

* * *

**Introducing Update IDs**

Every depth update increments a global sequence number.

```javascript
const updateId = ++ENGINE_STATE.lastUpdateId;
```

That update ID is included in every depth event.

This allows clients to process updates in order and detect if any updates were missed during transmission.

Although my current implementation increments one update ID per event (`U == u`), the same structure can later support batched updates where multiple sequence numbers are covered by a single message.

* * *

**Returning Depth Updates**

The matching engine now returns the generated depth update together with the order execution result.

```javascript
return {
  orderId,
  status: order.status,
  filledQty: order.filledQty,
  averagePrice,
  fills,
  depthUpdate,
};
```

This became the foundation for the next step.

Instead of clients repeatedly calling the REST API to fetch the latest order book, the engine could now publish these **incremental depth updates** over WebSockets, allowing every connected client to stay synchronized in real time while transferring only the data that actually changed.

* * *

### **Step 36: Implementing the WebSocket Layer**

With incremental depth updates working inside the matching engine, the next step was delivering those updates to clients in real time.

I wanted to keep the architecture loosely coupled, so instead of embedding a WebSocket server inside the matching engine, I created a **dedicated WebSocket microservice**.

The communication flow now looks like this:

```plaintext
    Client
       │
WebSocket Server
      ▲
      │ Redis Streams
      ▼
Matching Engine
```

The matching engine is responsible only for processing orders and generating market events.

The WebSocket service is responsible only for broadcasting those events to connected clients.

Separating these responsibilities makes each service simpler and allows multiple WebSocket servers to scale independently without affecting the matching engine.

* * *

**Publishing Depth Updates from the Engine**

Every successful order can potentially modify the order book.

After processing an order, the engine now returns a `depthUpdate`.

```typescript
const result = handleCreateOrder(message.payload);
```

If the response contains a depth update, it is immediately published to a Redis Stream.

```javascript
if ("depthUpdate" in data) {
    await publishDepthUpdate(depthUpdate);
}
```

Publishing is handled using `XADD`.

```typescript
await streamClient.xAdd(
    "depth-stream",
    "*",
    {
        payload: JSON.stringify(depthUpdate),
    },
);
```

Unlike Redis Pub/Sub, Redis Streams persist every message until it is acknowledged or removed, making them much more suitable for reliable event delivery.

* * *

**Creating a Dedicated WebSocket Microservice**

Instead of mixing WebSocket logic with matching logic, I created an entirely separate service.

Its responsibilities are:

*   Accept WebSocket connections
    
*   Manage client subscriptions
    
*   Read depth updates from Redis Streams
    
*   Broadcast updates to subscribed clients
    

This keeps the matching engine completely unaware of WebSocket connections.

* * *

**Websocket Server Code:**

```js
import { WebSocket, WebSocketServer } from "ws";
import { createClient } from "redis";

const STREAM = "depth-stream";

// Every websocket server has its own group
const GROUP = process.env.CONSUMER_GROUP!;
const CONSUMER = crypto.randomUUID();

const redis = createClient({ url: process.env.REDIS_URL }).on(
  "error",
  (error) => {
    console.error("Redis Stream client error", error);
  },
);

await redis.connect();

//creates a consumer group in Redis Streams.
//A consumer group can only be created once.
try {
  // startId options:
  // '$' - only new messages from this point forward
  // '0' - read all existing messages from the beginning
  // '1234567890123-0' - specific message ID
  // MKSTREAM - If depth-stream doesn't exist, create it first, then create the consumer group.
  await redis.xGroupCreate(STREAM, GROUP, "0", {
    MKSTREAM: true,
  });
} catch (error) {
  // BUSYGROUP means the group already exists
  if (error instanceof Error && error.message.includes("BUSYGROUP")) {
    console.log(`Group "${GROUP}" already exists`);
  } else {
    throw error;
  }
}

/**
 * Stores all active websocket subscriptions.
 *
 * Example:
 * {
 *   "depth.BTC": Set(ws1, ws2),
 *   "depth.ETH": Set(ws3)
 * }
 *
 * When a new depth update for BTC arrives,
 * only the sockets inside activeSubscriptions["depth.BTC"]
 * will receive the update.
 */
const activeSubscriptions: Record<string, Set<WebSocket>> = {};

const wss = new WebSocketServer({
  port: 8080,
});

console.log("WS Server Started");

poll();

async function poll() {
  while (true) {
    // Reading Messages with XREADGROUP
    // Engine -> XADD -> depth-stream -> xReadGroup()
    const result = await redis.xReadGroup(
      GROUP, // I'm reading as consumer group ws-server-1
      CONSUMER, // Inside a consumer group there can be multiple consumers.
      [
        {
          key: STREAM,
          id: ">", // The ">" ID means: give me messages never delivered to any consumer
        },
      ],
      {
        BLOCK: 0, // Wait forever until a message arrives.
        COUNT: 100, // Maximum messages to return at once.
      },
    );

    if (!result) continue;

    for (const stream of result) {
      for (const message of stream.messages) {
        console.log("Received from stream:", message);
        const depth = JSON.parse(message.message.payload);
        console.log(depth);

        const key = `depth.${depth.s}`;

        activeSubscriptions[key]?.forEach((ws) => {
          ws.send(JSON.stringify(depth));
        });

        await redis.xAck(STREAM, GROUP, message.id);
      }
    }
  }
}

wss.on("connection", (ws) => {
  console.log("Client Connected");

  ws.on("message", (data) => {
    const parsed = JSON.parse(data.toString());

    /**
     * {
     *   method:"SUBSCRIBE",
     *   params:["depth.BTC"],
     *   id:1
     * }
     */

    if (parsed.method === "SUBSCRIBE") {
      parsed.params.forEach((channel: string) => {
        // Create a subscription bucket if this is the
        // first client subscribing to the channel.
        if (!activeSubscriptions[channel]) {
          activeSubscriptions[channel] = new Set();
        }

        // Register this websocket connection
        // for the requested channel.
        activeSubscriptions[channel].add(ws);
      });

      // Acknowledge successful subscription.
      ws.send(
        JSON.stringify({
          id: parsed.id,
          result: null,
        }),
      );
    }

    if (parsed.method === "UNSUBSCRIBE") {
      parsed.params.forEach((channel: string) => {
        activeSubscriptions[channel]?.delete(ws);
      });

      // Acknowledge successful unsubscription.
      ws.send(
        JSON.stringify({
          id: parsed.id,
          result: null,
        }),
      );
    }
  });

  ws.on("close", () => {
    // Remove the socket from every subscribed channel
    Object.values(activeSubscriptions).forEach((clients) => clients.delete(ws));
  });
});
```

* * *

**Websocket Client code:**

```js
import axios from "axios";

type Orderbook = {
  bids: Record<string, string>;
  asks: Record<string, string>;
};

const orderbook: Orderbook = {
  bids: {},
  asks: {},
};
let orderbookInitialised = false;
const ws = new WebSocket("ws://localhost:8080");

const buffer: {
  updatedBids: [string, string][];
  updatedAsks: [string, string][];
  startOffset: number;
  endOffset: number;
}[] = [];

function updateOrderbook(
  updatedAsks: [string, string][],
  updatedBids: [string, string][],
) {
  updatedAsks.forEach(([price, qty]) => {
    if (qty === "0") {
      delete orderbook.asks[price];
    } else {
      orderbook.asks[price] = qty;
    }
  });

  updatedBids.forEach(([price, qty]) => {
    if (qty === "0") {
      delete orderbook.bids[price];
    } else {
      orderbook.bids[price] = qty;
    }
  });
}

ws.onopen = () => {
  ws.send(
    JSON.stringify({
      method: "SUBSCRIBE",
      params: ["depth.BTC"],
      id: 1,
    }),
  );
};

ws.onmessage = async (event) => {
  const message = JSON.parse(event.data);

  // Subscription acknowledged by the server
  if ("result" in message && message.id === 1) {
    console.log("Subscribed successfully");

    const res = await axios.get("http://localhost:3000/depth/BTC");

    const { bids, asks, lastUpdateId } = res.data;

    bids.forEach(([price, qty]: [string, string]) => {
      orderbook.bids[price] = qty;
    });

    asks.forEach(([price, qty]: [string, string]) => {
      orderbook.asks[price] = qty;
    });

    orderbookInitialised = true;

    let expected = lastUpdateId + 1;

    buffer.forEach((msg) => {
      if (msg.endOffset < expected) {
        return;
      }

      if (msg.startOffset > expected) {
        throw new Error("Sequence gap detected. Need resync.");
      }

      updateOrderbook(msg.updatedAsks, msg.updatedBids);

      expected = msg.endOffset + 1;
    });

    buffer.length = 0;

    console.log("Orderbook initialized");

    return;
  }

  // Actual depth update
  const updatedBids = message.b;
  const updatedAsks = message.a;
  const startOffset = message.U;
  const endOffset = message.u;

  if (!orderbookInitialised) {
    buffer.push({
      updatedAsks,
      updatedBids,
      startOffset,
      endOffset,
    });
  } else {
    updateOrderbook(updatedAsks, updatedBids);
  }
};

setInterval(() => {
  const bids = Object.entries(orderbook.bids).sort(
    (a, b) => Number(b[0]) - Number(a[0]),
  ); // Highest price first

  const asks = Object.entries(orderbook.asks).sort(
    (a, b) => Number(a[0]) - Number(b[0]),
  ); // Lowest price first

  console.clear();

  console.log("===== BIDS =====");
  bids.forEach(([price, qty]) => {
    console.log(`Price: ${price} | Qty: ${qty}`);
  });

  console.log("\n===== ASKS =====");
  asks.forEach(([price, qty]) => {
    console.log(`Price: ${price} | Qty: ${qty}`);
  });
}, 1000);
```

* * *

## Conclusion

Over the course of this series, we built a mini centralized exchange from the ground up and explored many of the core concepts used in real-world trading systems.

Starting with authentication and deposits, we moved on to designing an event-driven architecture using Redis, implemented a matching engine for both limit and market orders, built APIs for balances, orders, and market depth, wrote automated tests, benchmarked the system with k6, and finally added Redis-based snapshot persistence and recovery to make the engine resilient to crashes.

The final architecture of the exchange is shown below, bringing together all the components we've built throughout this series.

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/a451a42c-2fff-47df-8e25-8d3e5e9f896c.gif align="center")

This project is still a simplified exchange, but it demonstrates many of the same principles used by production trading platforms: keeping the matching engine as the source of truth, maintaining an in-memory order book for low-latency execution, decoupling services with message queues, and ensuring durability through snapshots and recovery.

If you'd like to explore the complete implementation, source code, or contribute to the project, check out the GitHub repository:

**GitHub:** [https://github.com/shubhamsinghbundela/centralized-exchange](https://github.com/shubhamsinghbundela/centralized-exchange)
