A reproducible NestJS + Postgres + Redis reference that pulls a hot-path list endpoint from ~200ms down to ~20ms. Same techniques I used in production on a multi-tenant messaging platform, rebuilt in the open so anyone can run the numbers themselves.
Two endpoints, same schema, same dataset: one deliberately slow, one optimized. Bring the stack up with docker compose up, run npm run bench, watch the delta on your own machine.
The production system this is based on is under NDA. What's public here is the technique library: every optimization below was applied in production to move a hot-path list endpoint from p95 ~200ms to p95 ~20ms at similar load.
It serves paginated orders for a multi-tenant SaaS, with customer and line-item joins per order. Under moderate load (50-200 rps) the slow version shows the usual suspects: N+1 queries (one round trip per order to fetch line items), an unindexed sort on (tenant_id, created_at), unbounded serialization of every relation, and no caching between requests. The fast version fixes each in turn: one join instead of N, a compound index on the hot filter, a projected DTO, and a Redis cache-aside for the top-N slices. p95 drops by roughly 10x.
flowchart LR
Client -->|HTTP| Nest[NestJS API]
Nest -->|slow: N+1| PG[(Postgres)]
Nest -->|fast: single join| PG
Nest -->|fast only: cache-aside| Redis[(Redis)]
subgraph Data
PG
Redis
end
| Route | What it does | What's wrong with it |
|---|---|---|
GET /orders/slow |
Lists orders for a tenant with customer and line items | N+1 populate, no compound index used, full-graph serialization, no cache |
GET /orders/fast |
Same output, optimized path | Single LEFT JOIN, compound index on (tenant_id, created_at DESC), projected DTO via class-transformer, Redis cache-aside (30s TTL on page 1) |
Both accept ?tenantId=<uuid>&page=<n>&limit=<n> and return the same JSON shape, so autocannon compares apples to apples.
Each one is measurable in isolation, and each one is called out in the source with a comment.
| # | Change | What it fixes | Why it helps |
|---|---|---|---|
| 1 | Compound index (tenant_id, created_at DESC) |
Table scan + external sort per request | Index-order scan on the hot filter |
| 2 | Replace findOne per row with a single LEFT JOIN |
N+1 round trips | 1 query instead of 1 + N |
| 3 | Projected DTO via class-transformer @Expose |
Serializing the full graph including hidden fields | Smaller payload, less CPU spent on JSON |
| 4 | Redis cache-aside on page 1 (30s TTL, tenant-scoped key) | Hot-tenant re-execution every request | Sub-ms hit path for the 80% case |
| 5 | Bigger pg connection pool and statement cache | Connection stall under load | Steady-state throughput |
You'll need Docker and Node 20+.
# 1. Bring up Postgres + Redis and seed 100 tenants x 1000 orders x 5 line items
docker compose up -d
npm install
npm run seed # ~10s; inserts ~500k rows
# 2. Start the API
npm run start:dev # listens on :3000
# 3. In a second terminal, run both benchmarks
npm run bench # writes bench/slow.json, bench/fast.json, and bench/summary.mdThe bench script hits each endpoint at 50 concurrent connections for 20 seconds after a 3-second warmup. That's roughly the load profile from the production incident this reproduces.
npm run bench prints a table like the one below and writes the raw autocannon output to bench/*.json. Numbers are indicative for a laptop-class machine (Ryzen 5, 16 GB). Run it on your own hardware and you should see comparable ratios.
| Endpoint | p50 | p95 | p99 | rps |
|---|---|---|---|---|
/orders/slow |
~110 ms | ~200 ms | ~310 ms | ~240 |
/orders/fast (cold cache) |
~14 ms | ~28 ms | ~45 ms | ~2 700 |
/orders/fast (warm cache, page 1) |
~1 ms | ~3 ms | ~6 ms | ~14 000 |
.
├── docker-compose.yml # Postgres + Redis
├── src/
│ ├── main.ts
│ ├── app.module.ts
│ ├── common/
│ │ └── cache.service.ts # thin Redis cache-aside wrapper
│ └── orders/
│ ├── orders.module.ts
│ ├── orders.controller.ts
│ ├── orders.slow.service.ts # deliberate anti-patterns, commented
│ ├── orders.fast.service.ts # each optimization is a labelled section
│ ├── entities/
│ │ ├── tenant.entity.ts
│ │ ├── customer.entity.ts
│ │ ├── order.entity.ts
│ │ └── line-item.entity.ts
│ └── dto/
│ └── order.dto.ts
├── scripts/
│ ├── seed.ts # deterministic seed
│ └── bench.mjs # autocannon runner + summary table
├── bench/ # your results after `npm run bench`
└── .github/workflows/ci.yml
It's not a benchmark of Postgres or Redis in isolation. It's a benchmark of the application-layer choices you make on top of them.
It's not a production-ready service. It's a case study. There's no auth, no rate limiting, no observability, none of the pieces a real service needs.
It's not a claim that these five changes are the only ones that matter. They're the ones that moved the number the most on this specific endpoint.
- Blog post walking through this repo: coming soon on dev.to.
- Companion repo:
nestjs-multitenant-starter- the tenant-isolation pattern behindtenant_idin this schema, using Postgres Row-Level Security so the boundary holds even when the app forgets to add a WHERE clause.
MIT © Hasibul Islam · @devhasibulislam