Skip to content

devhasibulislam/api-latency-case-study

Repository files navigation

api-latency-case-study

ci node nestjs license

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.

What the endpoint does

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.

Architecture

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
Loading

The two endpoints

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.

Techniques applied

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

Reproducing the benchmark

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.md

The 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.

What "before and after" looks like

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

Layout

.
├── 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

What this repo isn't

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.

Related

  • Blog post walking through this repo: coming soon on dev.to.
  • Companion repo: nestjs-multitenant-starter - the tenant-isolation pattern behind tenant_id in this schema, using Postgres Row-Level Security so the boundary holds even when the app forgets to add a WHERE clause.

License

MIT © Hasibul Islam · @devhasibulislam

About

Reproducible NestJS + Postgres + Redis reference for cutting a hot-path list endpoint from ~200ms to ~20ms. Two endpoints (slow/fast) benchmarkable with one command.

Topics

Resources

Stars

Watchers

Forks

Contributors

Languages