A practical field guide for improving API latency without breaking business behavior or hiding cost in another layer.
From my notebook
Where this came from
This is the checklist I use before changing a slow NestJS or PostgreSQL path. It grew out of production work where bounded reads, query cleanup, and carefully owned caching reduced average API latency by 40%. It is a working method, not a benchmark report.
My short checklist
Performance work starts before the profiler. I first map the request path: controller, service, repository, external calls, cache lookups, database queries, serialization, and response size. That map shows where time can be spent and where a change can accidentally alter behavior. In production systems, the dangerous performance fixes are the ones that look small but quietly change data shape, ordering, authorization, or pagination semantics.
The useful question is not "is PostgreSQL slow?" The useful question is "which user action is slow, for which data shape, under which constraints?" A dashboard query with ten rows has a different failure mode than an export endpoint, a chat inbox, or an operator search screen. I capture the endpoint, payload size, filters, sort order, current latency, and the business rule the response must preserve.
Most slow API paths I see come from broad reads. An ORM include graph grows over time because each new feature adds one more relation. Eventually a simple list endpoint loads data for a detail page, admin page, notification badge, and historical audit all at once. I prefer scoped repository methods with explicit select fields, stable limits, and separate detail endpoints when the UI truly needs deeper data.
Redis can hide waste, but it can also create stale behavior that is harder to debug than the original slow query. I cache data when the owner, invalidation event, TTL, and fallback behavior are obvious. If invalidation is vague, I usually fix the query first. A cache should reduce repeated cost, not become a second source of truth.
Some work does not belong in the request path: sending emails, generating reports, syncing external APIs, processing files, or calling slow third-party services. Queues help when the product can accept eventual completion and when retry/idempotency rules are defined. Without those rules, async work only moves the bug to a worker.
The final step is verification. I do not call a performance change finished until the endpoint still returns the same shape, the slow case is measurably faster, and the operational risk is understood. Good performance work is boring in the best way: fewer rows read, fewer bytes returned, fewer repeated calls, and no surprise behavior changes.
const orders = await prisma.order.findMany({
where: { customerId, status: { in: ["open", "paid"] } },
select: {
id: true,
status: true,
total: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
take: 50,
});