nestjs · · 19 min read

How to Diagnose Slow NestJS Endpoints and Find Performance Bottlenecks

Use request traces, CPU profiles, event-loop delay, and connection-pool metrics to diagnose slow NestJS endpoints. Locate bottlenecks in DTO conversion, serialization, queries, and queueing, then verify improvements with load tests.

Mttao Mttao @bearboy80 4,142 words 中文 →

Where an order-list request spends its time

An order-list endpoint can pass through quite a few NestJS components. A controller calls a service, which calls a pricing service and a repository. Guards, pipes, and interceptors wrap the whole thing. When the endpoint gets slow, those layers are an obvious suspect.

At the call site, this.ordersService.list() tells us little about the work inside. It might read an in-memory value, convert objects, query a database, or call another service. Counting service layers won’t show where the time goes.

I’d start by checking where the request computes, where it waits, and how many times it processes the same data. This article follows those costs through an order-list endpoint. The query counts and timings are illustrative, not measurements from a production service.

Does another service call mean another dependency lookup?

During startup, Nest scans modules and providers, reads controller and route metadata, builds dependency relationships, and creates default-scoped instances. For HTTP routes with static dependency trees, it also prepares handler wrappers and caches some parameter and response metadata during route registration.

An incoming request runs the handler that has already been registered. Consider this structure:

OrdersController
  → OrdersService
    → PricingService
    → OrdersRepository

If these providers are default-scoped singletons, with no request-scoped dependency making the tree non-static, this.ordersService.list() is an ordinary JavaScript method call on an injected object. Entering PricingService doesn’t trigger another round of constructor resolution. Entering the repository doesn’t make Nest search every module again.

A singleton here means a shared instance for a provider registration. Registering the same class in several places can still produce several instances.

Much of the metadata left by decorators is handled during initialization. If you suspect reflection overhead, look for metadata reads on the request path, including those in custom guards and interceptors. Startup module scanning belongs in a separate measurement.

For slow startup, check module initialization, constructors, and connection setup. For slow requests after warm-up, check the code that runs on each request.

Time spent in guards, pipes, and interceptors

A typical successful request follows this order:

Middleware
  → Guards
  → Interceptors: inbound
  → Pipes
  → Controller / Service
  → Interceptors: outbound
  → HTTP adapter: serialization and send

A guard can reject a request, and an interceptor can return a result without calling the next handler, so requests don’t necessarily traverse the entire chain. Exception filters handle uncaught exceptions; they aren’t a step on the normal success path.

Dispatch, argument handling, and asynchronous result handling all have a cost. Which costs apply depends on the components attached to the route. In NestJS v11.1.6, an empty interceptor list calls the next handler directly, skipping the RxJS wrapping used for the interceptor chain.

Two guards can do very different amounts of work. One might read role metadata and compare permissions. Another might query Redis, check a permissions database, and call an identity service. For the second guard, a single duration isn’t enough: split out network round trips, connection acquisition, and authentication retries.

The same applies to pipes and interceptors. A validator may query a database; a logging interceptor may serialize a large object. Timing the component as a whole can make all that work look like framework overhead.

Which objects does request scope create?

With Scope.REQUEST, Nest needs to resolve instances for the request. Take this dependency chain:

OrdersController → OrdersService → OrdersRepository

If OrdersService is request-scoped, its controller also needs resolution per request. An otherwise static repository doesn’t automatically become request-scoped.

Scope bubbles from a request-scoped dependency to its consumers. It doesn’t turn every dependency further down the chain into a new object per request.

For a non-static route, Nest obtains or creates a ContextId and resolves the dependencies required in that context. A request-scoped provider can be reused within the same context; each method call doesn’t create a fresh instance.

Trace the dependency relationships when investigating this cost. How many objects does one Scope.REQUEST registration cause Nest to create for each request?

A small object holding a request ID usually has a modest construction cost. A service constructor that creates SDK wrappers, allocates a large cache, or brings in many dependencies can cost much more. As traffic rises, those allocations also give the garbage collector more work.

Check global APP_GUARD, APP_PIPE, and APP_INTERCEPTOR registrations too. Their request-scoped dependencies can affect many controllers.

If the code uses ModuleRef.resolve(), check whether it reuses a context:

// Omitting contextId creates a separate resolution context for a scoped provider.
const isolated = await moduleRef.resolve(ScopedWorker);

// Assumes Nest has already associated a DI context with this request.
const contextId = ContextIdFactory.getByRequest(req);
const requestWorker = await moduleRef.resolve(ScopedWorker, contextId);

Repeatedly using the first form in a loop may create new scoped dependency trees. The second assumes the request already has an associated DI context. Otherwise, create and reuse a context explicitly. If the provider injects REQUEST, register the request as well; getByRequest() doesn’t do that registration for you.

TRANSIENT creates instances per consumer. It doesn’t automatically make a singleton consumer request-scoped. A transient instance held by a singleton may live as long as that singleton.

If request scope only carries a request ID or tenant ID, explicit parameters or AsyncLocalStorage may be worth considering. AsyncLocalStorage still has context propagation and retention costs. Keep the stored context small instead of putting the entire request, entity collections, or large responses in it.

DTO conversion can traverse the same data several times

An order query might include date ranges, status lists, sorting options, and nested filters. A bulk operation might also carry a long array of order details.

ValidationPipe may convert that input into DTO instances before validating it recursively. Setting transform: false doesn’t necessarily skip that conversion.

In NestJS v11.0.0, when a parameter’s metatype enters the DTO validation branch, ValidationPipe calls plainToInstance() before passing the result to class-validator. The transform option mainly determines whether the validated instance is returned. Some validator options also cause a conversion back to a plain object.

If class-transformer is prominent in a CPU profile, disabling transform alone may not remove it.

Validation cost depends on field counts, array lengths, nesting depth, constraints per field, and unknown fields. @ValidateNested(), each: true, and whitelist can add traversal work.

Two DTOs can both have three nesting levels while containing vastly different amounts of data. One may have a handful of scalar values; the other may contain thousands of line items. Record array lengths and field counts as well as depth.

Look for repeated processing, too. A global validation pipe may overlap with one attached to a route or parameter. Several DTO parameters may consume the same request body. A service may convert an object that was already converted at the boundary. Following the data through the endpoint helps expose these repetitions.

Test invalid input as well. Large arrays, many unknown fields, and elaborate error trees can consume substantial CPU. stopAtFirstError doesn’t stop validation of the entire payload at the first error, and disableErrorMessages doesn’t replace an input-size limit.

Start with payload and array limits, then consolidate redundant validation and conversion. Check that unknown-field handling and error formats still behave as expected afterward.

Returning from the controller leaves work to do

After the query completes, the endpoint may still map entities, filter fields, and produce JSON.

With ClassSerializerInterceptor, a return value may pass through classToPlain(). When a specific type is configured, a plain object may first become an instance and then become a plain object again. The HTTP adapter handles the result afterward. Ordinary Express object responses also undergo JSON serialization.

Choosing fields and converting types reshapes an object. Encoding it as a JSON string is another pass. Both can traverse the full result.

If the repository maps the data, the service calls map() again, and the controller makes another copy, that adds several passes before the class serializer and JSON.stringify() even run.

Keep mappings that enforce domain rules or remove sensitive fields. Look for copies that serve no purpose, and avoid fetching columns that will only be discarded later.

Large JSON parse and stringify operations occupy the event loop, delaying other requests in the same process as well. A timer inside the controller misses work performed after it returns, including later serialization, GC, and response-write waits.

Computation after await still runs on the main thread

The database query in this function is asynchronous:

async function listOrders() {
  const orders = await repository.findOrders();
  return expensiveMappingAndSorting(orders);
}

Once it completes, expensiveMappingAndSorting() runs on the current JavaScript thread. Marking a function async doesn’t move its computation to another CPU core.

Large sorts, deep copies, expensive regular expressions, synchronous encryption, and synchronous compression can all occupy the main thread. While that code runs, other requests’ callbacks, validation, and response handling have to wait.

Promise.all() can overlap independent I/O waits. It can’t run synchronous JavaScript in parallel on the same thread. If each task does substantial computation before returning a promise, that computation still runs on the same thread.

Many promise continuations, recursive process.nextTick() calls, or dense microtask scheduling can also delay I/O callbacks. But one await doesn’t necessarily require a full I/O polling cycle, so counting await expressions won’t give you a latency estimate.

Some asynchronous file operations, crypto operations, dns.lookup(), and zlib use the libuv thread pool. These can queue in that pool rather than in JavaScript execution. For CPU-heavy JavaScript, worker_threads may help, usually through a reusable pool with a bounded size.

Increasing the libuv pool won’t make ordinary JavaScript use multiple cores. Creating workers isn’t a default fix for database I/O either.

Short-lived objects add GC work

DTOs, array mappings, log records, and trace spans allocate memory. More short-lived objects mean more collection work. The phases that pause the main thread delay request execution, so look at those pauses rather than treating the entire GC cycle as a stop-the-world event.

If heap usage falls back after collection, allocation pressure may be the main concern. If the post-GC baseline keeps rising, investigate objects retained by caches, closures, pending promises, or exporter queues.

RSS growth alone doesn’t prove a JavaScript heap leak. heapUsed, external, and arrayBuffers cover different memory categories. arrayBuffers is already included in external, so don’t add them together.

A small CPU saving can make room for more requests

Suppose a request does 0.8 ms of local processing, starts a 30 ms database call and an independent 18 ms HTTP call together, then spends 1.7 ms mapping and serializing the result. In this simplified model, the two I/O waits overlap, and only the two local segments count toward main-thread CPU time. These are hypothetical numbers, not a NestJS benchmark.

Without queueing, the critical path is:

0.8 + max(30, 18) + 1.7 = 32.5 ms

The main thread’s CPU demand is:

0.8 + 1.7 = 2.5 ms/request

For a JavaScript main thread with a CPU core to itself, ignoring GC, scheduling, CPU throttling, and other bottlenecks, the ideal CPU capacity bound is:

1000 / 2.5 = 400 requests/second

This estimates the main thread’s CPU bound, not measured throughput for the whole endpoint. If the database or HTTP dependency reaches capacity first, actual throughput will be lower. Near the CPU bound, requests also cannot be expected to retain the earlier unqueued 32.5 ms latency.

Using 1000 / 32.5 would incorrectly treat overlapping I/O waits as time during which the main thread is occupied.

Remove the initial 0.8 ms entirely, and unqueued latency falls only from 32.5 ms to 31.7 ms. CPU demand falls to 1.7 ms per request, so the ideal CPU bound becomes 1000 / 1.7 ≈ 588 requests/second.

A user may never notice that sub-millisecond improvement. A service near its main-thread capacity can use the saved CPU time to handle more requests.

If that 0.8 ms segment becomes ten times faster instead, it takes 0.08 ms and the critical path becomes 0.08 + 30 + 1.7 = 31.78 ms. The whole endpoint doesn’t become ten times faster. Amdahl’s law describes this limit: the overall gain depends on the fraction of the original work being accelerated.

Read CPU profiles with the same distinction in mind. A function using 50% of CPU time doesn’t necessarily account for 50% of response time when the request spends most of its time waiting for the database.

Near capacity, requests spend more time waiting

At low load, extra synchronous work mainly adds time to the current request. Near capacity, it also makes later requests queue.

As requests accumulate, their promises, DTOs, and telemetry objects remain in memory. Allocation and GC pressure can rise, while timeouts and retries may add more downstream load.

You can’t simply add a low-concurrency overhead measurement to a high-concurrency p99. Queueing delay grows nonlinearly as a system approaches saturation.

Little’s law provides a useful consistency check: average requests in flight = average throughput × average time in the system. Within the same stable measurement boundary, 300 requests/second and an average duration of 40 ms = 0.040 seconds imply 300 × 0.040 = 12 requests in flight on average.

Use averages here, not p99. Don’t combine a gateway’s request count with a duration measured only inside the controller.

How 50 orders turn into 101 queries

Suppose the endpoint fetches 50 orders, then issues one customer query and one line-item query for each order, without batching or caching. That makes:

1 + 2 × 50 = 101 queries

Awaiting each query in a loop puts many round trips on the critical path. Launching all of them through Promise.all() may fill the connection pool almost immediately.

Both approaches still execute 101 queries. Batch reads or joins can reduce the query count. Selecting only needed columns, or aggregating in the database, can reduce data transfer and application work. TypeORM’s performance documentation covers N+1 queries and unnecessary entity construction.

After combining queries, check how many rows come back. If each of 50 orders has three line items and two payments, joining both one-to-many relationships can produce 50 × 3 × 2 = 300 rows. The ORM may assemble those into 50 order entities, but the repeated columns have already been transferred, read, and processed.

A detail page that needs complete relationships may benefit from joins and entity mapping. A list showing only an order number, status, amount, and customer name may be easier to control by fetching a stable page of orders, then batch-loading the extra fields it needs.

For read-only projections that don’t need full entities, consider getRawMany(). It avoids some entity construction, but leaves you responsible for aliases, types, missing values, and DTO mapping. When switching from getMany(), check that existing redaction rules still apply.

Pagination depends on how the product is used. Offset pagination supports jumping to a specific page, but deep pages may require skipping many rows. Keyset pagination continues from a stable sort key and suits sequential browsing, but doesn’t naturally support jumping to page N. With complex joins, inspect the SQL the ORM actually generates; pagination may involve more than appending OFFSET/LIMIT.

Fast SQL can still leave requests waiting for a connection

Measure connection acquisition separately from query execution. With node-postgres, requests wait for an available client when the pool is full, and waitingCount reports how many are queued.

Count connections across replicas when scaling. Six pods, each with one pool capped at ten connections, can open sixty connections in total, before accounting for other services.

A larger pool may reduce local waiting, or it may move the queue into the database, a proxy, or lock contention. Check query counts, transaction duration, acquisition time, and database capacity together before increasing max.

Before adding caching and retries

A remote cache hit still involves key construction, a network round trip, deserialization, and final HTTP serialization. A miss also has to fetch the underlying data and populate the cache.

If order visibility depends on tenant, user, or permissions, the cache key needs to distinguish those conditions. A URL alone may give two users the same result. A permission version alone may be insufficient too: users with the same permission version might still be allowed to see only their own orders.

Caching a ready-to-send response requires preserving the field visibility rules for that identity. Caching internal data requires authorization and redaction after retrieval, before returning it to the client.

TTL starts when a cache entry is written. An old request that finishes after invalidation can write stale data back with a fresh TTL. Replication lag and failed invalidation can also leave old data available. The TTL therefore isn’t automatically an upper bound on business-data staleness.

For hot keys, request coalescing or single-flight can reduce duplicate fetches after expiry. Bound both the number of waiters and their wait time so they don’t accumulate indefinitely.

In GraphQL, a request-local DataLoader can batch suitable loads within its scheduling window. Batch results must match the input keys in both order and length, with missing results represented explicitly. Loaders are usually created per request to avoid sharing cached results across users or tenants.

Timeouts need a separate check. If the timeout wins in Promise.race([work, timeout]), the race settles, but the database query or RPC doesn’t automatically stop.

If the client retries while the original task still holds resources, both attempts can run at once. Check whether the underlying client supports cancellation, whether deadlines propagate downstream, and what cancellation means for transactions and idempotency.

Retries at several layers can compound the problem. If the gateway, Nest service, and database client all retry, a slow dependency can receive even more work. Decide which layer owns retries, bound the retry budget, and use backoff with jitter.

Logging and tracing also consume CPU

A long call chain can generate a lot of telemetry. If every layer logs complete arguments, return values, and stacks, formatting, redaction, allocation, and JSON encoding happen before the record reaches the output stream.

Whether stdout and stderr writes are synchronous depends on the platform and destination. Measure with a logging pipeline comparable to production.

If disabling logs makes the endpoint noticeably faster, measure record construction and output separately. Fewer fields or a lower sampling rate may help the first; batching output may help the second. That distinction tells you where the improvement comes from.

Metric labels need bounded cardinality. Route templates, status-code classes, and response-size buckets support useful aggregation. Order IDs, user IDs, and raw URLs can create far more series. Trace attributes may retain identifiers when needed for diagnosis, subject to sampling, sensitive-data handling, and volume limits.

When switching to Fastify is worth testing

Fastify changes HTTP routing, parsing, and response handling. Nest guards, pipes, class validation, and class serialization still run.

Fastify’s compiled validation and response serialization require the appropriate JSON Schema on the registered route. DTO classes, class-validator decorators, and Swagger documentation don’t automatically become a Fastify runtime route schema.

If database access, DTO conversion, and response mapping account for most of the cost, changing adapters may have a limited effect. Adapter differences deserve closer attention when HTTP processing makes up a substantial share of a lightweight endpoint’s work.

Keep authentication, input limits, error formats, and middleware behavior equivalent when comparing adapters. An implementation that skips validation is doing less work, so its result isn’t a like-for-like comparison.

Check what your timer actually includes

“Endpoint duration” might mean the time until a client finishes reading the response, or just the controller’s execution time. Check the start and end points before comparing measurements.

Nest interceptors run after guards, so an interceptor timer excludes earlier middleware and guard execution. Requests rejected by a guard never enter the later interceptor.

RxJS finalize() runs on completion, error, or unsubscription; it doesn’t mean the HTTP client has received the response. Node’s finish event means the last response segment has been handed to the operating system.

For ordinary JSON endpoints, it can help to measure client completion, the earliest server middleware, the interceptor, downstream spans, and response finish separately. Streams, SSE, and downloads need completion definitions suited to their protocols.

Don’t add parent and child span durations together. A parent already includes its children, and concurrent children can overlap. To calculate a parent’s exclusive time, subtract the union of child intervals rather than subtracting every child duration individually.

Compare CPU, event-loop delay, and connection waits

monitorEventLoopDelay() measures event-loop delay in nanoseconds. eventLoopUtilization() measures active and idle event-loop time; it isn’t process CPU utilization.

When process CPU is high, determine whether the main thread, native threads, or other work is responsible. Low process CPU doesn’t rule out quota throttling, synchronous waits, or downstream queues. Container CPU limits can introduce throttling that affects elapsed time.

To inspect CPU, allocations, and GC, run these separately on an isolated test replica:

mkdir -p diagnostics

# Enable one diagnostic per run and compare with an unprofiled baseline.
node --cpu-prof --cpu-prof-dir=diagnostics dist/main.js
node --heap-prof --heap-prof-dir=diagnostics dist/main.js
node --trace-gc dist/main.js > diagnostics/gc.log 2>&1

A CPU profile shows where CPU time goes; database waiting won’t appear as a hot function. Diagnostic tools also add overhead. Heap snapshots in particular can pause the process and increase memory pressure, so avoid taking one casually on a production instance with little headroom.

These combinations can narrow the investigation. Confirm the suspected cause with a profile or an experiment that changes one variable:

What you observeWhat to inspectChanges to evaluate
CPU, event-loop delay, and p99 rise togetherJSON, DTO conversion, sorting, synchronous loggingSmaller payloads and fewer repeated traversals or serializations
Event-loop delay stays steady while connection acquisition and waitingCount riseQuery counts, transaction duration, connection budgets across replicasBatch queries, shorter transactions, then pool sizing
Large requests or responses are disproportionately slowArray lengths, nested-object counts, returned fieldsInput limits, narrower projections, tests grouped by payload size
Allocations and GC increase with trafficRequest scope, repeated mappings, logging and tracing objectsFewer per-request instances and unnecessary allocations
Downstream load keeps rising after timeoutsCancellation of original work, retries at multiple layersPropagated deadlines, cancellation handling, retry budgets
Lightweight endpoints remain limited by HTTP processingAdapters under equivalent authentication, validation, and response behaviorFastify and process capacity

Keep requests arriving on schedule during load tests

Fix Node, Nest, adapter, and ORM versions. Keep dataset size, indexes, authorization results, request mix, response fields, logging, pod count, and resource quotas consistent too.

Separate cold-start, initial-request, and warmed-up results. Change one variable at a time while preserving authentication, validation, and response fields. Otherwise the two runs aren’t doing equivalent work.

Alongside fixed-concurrency closed-loop tests, consider open-loop tests with a scheduled arrival rate. A closed-loop client waits for responses before sending more requests, so it reduces the offered load when the server slows down. That can conceal overload. In an open-loop test, track intended send times, load-generator queues, and client capacity, and account for coordinated omission when measuring tail latency.

Increase the arrival rate in steps. Record successful throughput, errors, timeouts, latency distributions, in-flight requests, and downstream queues. Keep timeout counts in the results; averaging only successful requests leaves out the ones that never completed before the deadline.

Where I’d start on this endpoint

I’d take a slow-request trace and check whether most of the time goes to database round trips, connection acquisition, or local processing. If it executes 101 queries, reduce that count first. If DTO conversion or JSON dominates the CPU profile, inspect data volume and repeated traversal. If requests wait for connections, check transaction duration and the total connection budget together.

After each change, rerun the same inputs, permissions, and response fields. Compare successful throughput, p99, and error rates. Measure both individual request time and queueing near capacity. If the service still hits an HTTP or main-thread limit after removing redundant work, test Fastify, more processes, or a worker pool as appropriate to that bottleneck.

Whether to remove a service layer can usually remain a maintainability decision. A performance-driven change needs evidence of costly work in that layer.

Related reading: Getting started with NestJS · Choosing between NestJS and Express

References

The source references use NestJS v11.0.0 and v11.1.6; Node API details mainly refer to v22.14.0. Documentation pages may change, so compare implementation details with the versions in your project.

  1. NestJS v11.1.6 RouterExplorer
  2. NestJS v11.1.6 RouterExecutionContext
  3. NestJS request lifecycle
  4. NestJS v11.1.6 InterceptorsConsumer
  5. NestJS injection scopes
  6. NestJS v11.1.6 InstanceWrapper
  7. NestJS ModuleRef and scoped resolution
  8. NestJS v11.0.0 ValidationPipe
  9. class-transformer v0.5.1 TransformOperationExecutor
  10. class-validator v0.14.1 ValidationExecutor
  11. NestJS v11.0.0 ClassSerializerInterceptor
  12. Express v4.21.2 response implementation
  13. Node.js: Don’t block the Event Loop or the Worker Pool
  14. Node.js v22.14.0 command-line options and diagnostics
  15. Node.js worker_threads
  16. Node.js v22.14.0 AsyncLocalStorage
  17. Node.js tracing garbage collection
  18. Node.js v22.14.0 process CPU, memory and I/O
  19. Cornell Virtual Workshop: Amdahl’s Law
  20. Linda Green: Queueing Theory and Modeling
  21. John D. C. Little: Little’s Law as Viewed on Its 50th Anniversary
  22. TypeORM performance optimization with QueryBuilder
  23. TypeORM select queries, entities, raw results and pagination
  24. node-postgres Pool API
  25. node-postgres Pool Sizing
  26. Redis cache-aside and request coalescing
  27. DataLoader batching and request-local caching
  28. MDN Promise.race
  29. Google SRE: Addressing Cascading Failures
  30. NestJS Fastify performance adapter
  31. Fastify validation and serialization
  32. NestJS v11.0.0 FastifyAdapter route registration
  33. RxJS finalize operator
  34. Node.js v22.14.0 HTTP response events
  35. Node.js v22.14.0 performance hooks
  36. Kubernetes resource requests, limits and CPU throttling
  37. wrk2 and coordinated omission
Mttao

Mttao GitHub ↗

Exploring technology and life's wisdom

Related Posts

View all →
  1. 01 NestJS or Express? Pick by How Big the Project Will Get nestjs· Aug 28, 2026
  2. 02 NestJS for Beginners: From Your First Project to a Task API nestjs· Aug 27, 2026
  3. 03 NestJS vs Next.js: Backend Framework, React Full Stack, or Both? nextjs· Aug 25, 2026

/ Comments