WhatsAppDamascusSat – Thu·10:00 AM – 7:00 PM

Why your NestJS API is slow

Two numbers explain most of it: how many queries one request ran, and how long the slowest took. Sixty small queries is a structure problem, not an index one.

PerformancePublished 3 min read

Log the queries before you guess

An endpoint that takes 900ms is not a mystery, but it is unreadable from the outside. Before changing anything, make the request tell you where its time went. Turn on query logging in the ORM and count the statements for one request:

ts
// TypeORM
new DataSource({logging: ['query'], maxQueryExecutionTime: 100});

Two numbers decide almost every case. How many queries did one request run, and how long did the slowest one take? A single 400ms query is an index problem. Sixty queries of 4ms each is a structure problem, and it is far more common.

The N+1, which is most of it

You fetch fifty orders, then read order.customer in a loop, and the ORM issues fifty more queries without telling you. It looks like property access. It is network round trips.

ts
// one query per order, silently
const orders = await repo.find();
for (const o of orders) console.log(o.customer.name);

// one query, joined
const orders = await repo.find({relations: {customer: true}});

Lazy relations are the usual cause, and the reason they hurt is that they make the expensive thing look free. If you use GraphQL, the same problem arrives per-field across resolvers and the fix is a DataLoader that batches within a tick rather than eager relations.

Read the query count for your three slowest endpoints. If it scales with the number of rows returned, you have found the bug and everything below is secondary.

The pool, which is invisible until it is not

Every request holds a database connection while it works. The pool has a size, usually about ten, and when all of them are busy the next request waits — not slowly, just waits, and the latency you measure is queueing rather than work.

Raising the pool size is the wrong first move; it usually just moves the contention into the database. Shorten how long each request holds a connection instead: no HTTP calls inside a transaction, no await on something unrelated between opening and committing, and never a transaction spanning a request you do not control.

Where the time hides in Nest specifically

  • Global interceptors and pipes run on every route. A ValidationPipe with transform on a large payload, or a logging interceptor that serialises the whole response body, is a cost you pay per request and never see in a query log.
  • class-transformer is not free. plainToInstance over a few thousand rows is real CPU. Return the shape you need from the query instead of fetching entities and mapping them.
  • Request-scoped providers rebuild the injection subtree per request. Convenient, and much more expensive than the default singleton scope. Use them deliberately, not by habit.
  • await in a loop is a sequence. Promise.all over independent work is usually a one-line change with a large effect.

Caching, last

Caching is the step after the structure is right, because a cache in front of an N+1 hides it, and it will come back as a stampede the first time the cache expires under load.

When you do add one, cache the expensive read rather than the whole response, give it an explicit TTL, and decide what invalidates it before you ship it. A cache with no invalidation story is a bug with a delay on it.

The one measurement that matters

After each change, measure the same endpoint the same way — concurrency you actually expect, and the 95th percentile rather than the average. Averages hide the requests that lose you customers, and the reason to fix the N+1 first is that it is the one that gets dramatically worse exactly when you are busiest.