Last updated: July 2026
Your Node.js app is slow and you are not sure where. The response time dashboard shows spikes but not causes. The logs say nothing useful. CPU looks fine. Memory looks fine. Users are complaining anyway.
This is the standard Node.js performance debugging experience. The single-threaded event loop, async-everything execution model, and connection pool sharing across all requests make Node.js performance problems different from what you see in Ruby or Python. The tools and techniques that work for Rails or Django do not translate directly.
This guide covers what to monitor in a production Node.js application, where the common problems hide, and how to find them.
What to Monitor in Node.js
Event Loop Lag
The event loop is the bottleneck. Every request, every database callback, every timer shares it. When a synchronous operation blocks the event loop, every other request waits. A 50ms CPU-bound function running inside a request handler adds 50ms to the response time of every concurrent request, not just the one that triggered it.
Monitor event loop lag continuously. Healthy applications show lag under 10ms. Sustained lag above 50ms means something is blocking, and your response times are degrading across the board.
Common causes: JSON serialization of large payloads, synchronous file operations (fs.readFileSync in a request path), CPU-intensive computation (image processing, PDF generation, heavy regex), and garbage collection pauses on large heaps.
Database Query Performance
Most Node.js web applications spend the majority of their response time in database queries. The async execution model makes this less obvious than in synchronous languages because the query is not blocking the event loop (assuming your database driver is async), but it is still the clock on your response time.
Track query duration per request, not just aggregate query time. A request that runs 15 fast queries is often slower than one that runs 2 slow queries because of round-trip overhead and connection pool contention.
N+1 queries are common in Node.js, especially with Prisma. The “loop and query” pattern feels natural in JavaScript:
const users = await prisma.user.findMany();
const profiles = await Promise.all(
users.map(user => prisma.profile.findUnique({ where: { userId: user.id } }))
);
This runs 101 queries for 100 users. Promise.all fires them concurrently, so the bottleneck is connection pool saturation and database load, not sequential latency. In development with 5 users, the pool handles it fine. In production with 500 users, you exhaust the pool and every other request queues behind it. Code review rarely catches these because each individual query is intentional.
Memory Usage and Garbage Collection
Node.js uses V8’s garbage collector, which pauses execution to reclaim memory. Small, frequent collections (minor GC) are normal. Large collections (major GC) that pause execution for 50-200ms are a problem in production. They show up as periodic latency spikes that do not correlate with traffic patterns.
Monitor heap usage over time. A steadily growing heap that never drops back to a stable baseline is a memory leak. Common sources: event listeners that are added but never removed, closures holding references to large objects, unbounded caches (using a plain object as a cache without eviction), and global state that accumulates per-request data.
Connection Pool Saturation
Express and NestJS applications share a single database connection pool across all concurrent requests. When the pool is exhausted, new queries queue until a connection is released. The waiting is async (it does not block the event loop), but it adds wall-clock time to every request that needs a database connection. Under load, this creates a cascading delay: requests pile up waiting for connections, response times climb, and timeouts start firing.
Track pool utilization as a percentage. Healthy is under 70%. Above 85% consistently means you need to either increase the pool size, reduce query count per request, or move work to background jobs.
HTTP External Call Latency
Node.js applications frequently call external APIs (payment processors, search services, third-party data providers). These calls are async and non-blocking, but they still add wall-clock time to your response. A downstream service that responds in 200ms at p50 but 2 seconds at p99 will occasionally make your endpoints slow in ways that are hard to reproduce.
Monitor external call duration as part of each request trace, not just as a separate metric. You need to see that the slow request was slow because the payment API took 3 seconds, not because your code was inefficient.
Where Node.js Performance Problems Actually Hide
Middleware Stacking in Express
Express middleware runs in order for every request. Each middleware function adds latency. A common pattern is loading the full user session, checking permissions, and parsing the request body for every route, including routes that do not need all of that.
Profile your middleware chain by tracing how much time each middleware function adds. If authentication middleware is running on your health check endpoint, that is wasted time on every load balancer probe.
Prisma Client Overhead
Prisma adds query overhead compared to raw SQL because it generates queries at runtime and serializes results through its type layer. For most applications this is negligible. For high-throughput endpoints that execute many queries per request, the overhead compounds.
Use include instead of separate queries to reduce round trips. Prefer findMany with where: { id: { in: ids } } over looping with findUnique. For performance-critical paths, consider raw queries with prisma.$queryRaw.
NestJS Dependency Injection
NestJS uses singleton-scoped providers by default, so DI resolution happens once at startup. But if you use Scope.REQUEST providers (for per-request state like tenant isolation or user context), NestJS creates a new instance of that provider and its entire dependency chain on every request. A request-scoped provider with expensive dependencies (database connections, external API clients) adds measurable overhead.
The fix: keep providers singleton unless you have a specific reason for request scope. If you need per-request data, inject it through middleware or request context rather than through DI scope.
Sequential Async Operations
JavaScript’s await keyword makes it easy to write sequential code that should be parallel:
// Sequential: 600ms total (200ms + 200ms + 200ms)
const user = await getUser(id);
const orders = await getOrders(id);
const recommendations = await getRecommendations(id);
// Parallel: 200ms total
const [user, orders, recommendations] = await Promise.all([
getUser(id),
getOrders(id),
getRecommendations(id),
]);
When the three calls are independent, Promise.all runs them concurrently. This is obvious in isolation but hard to spot in real code where the calls are spread across service methods and helper functions.
How to Monitor Node.js in Production
APM Tools
An APM tool that instruments your Node.js application at the framework level gives you the most complete picture. You want automatic tracing that shows time spent in each middleware, route handler, database query, and external call for every request.
Scout Monitoring instruments Express and NestJS applications with automatic N+1 detection in Prisma and raw SQL. Each request trace shows the full code path with timing per operation, so you can see that the /api/orders endpoint is slow because it runs 47 Prisma queries when it should run 3. Error monitoring is integrated, so you see the exception alongside the trace that caused it.
Scout’s MCP server and CLI give AI coding agents like Claude Code and Cursor direct access to your Node.js performance data. When your agent identifies a slow endpoint, it can pull the trace from Scout, see the N+1 pattern, and suggest the fix with include or Promise.all. The anomaly detection feature alerts you when an endpoint’s response time deviates from its normal pattern, without setting thresholds.
Other options: Datadog provides broader infrastructure coverage alongside APM. New Relic offers a mature Node.js agent with distributed tracing. Both are better suited for platform teams with dedicated operations staff. For dev teams running Express or NestJS without a dedicated SRE, Scout provides the fastest path from “something is slow” to “here is the code to fix.”
Built-in Diagnostics
Node.js ships with useful diagnostics that complement APM:
--inspect enables the Chrome DevTools debugger for CPU profiling and heap snapshots. Useful for investigating specific problems, but not for continuous production monitoring.
process.memoryUsage() returns heap and RSS numbers. Log these periodically to track memory trends. A steadily growing heapUsed that does not return to a baseline after garbage collection indicates a leak.
perf_hooks provides high-resolution timing. The PerformanceObserver API can measure specific code paths in production with low overhead.
What Your Monitoring Should Show You
A useful Node.js monitoring setup answers these questions without requiring you to build dashboards or write queries:
- Which endpoints are slowest right now?
- What changed? Did a recent deploy introduce a regression?
- Is the slowness from your code, your database, or an external service?
- Are there N+1 query patterns you do not know about?
- Is memory growing over time?
If your monitoring tool requires you to configure each of these yourself, you will skip most of them. The tools that surface these automatically are the ones that actually get used by teams without dedicated operations staff.
Getting Started
Install the Scout Node.js agent and see results in 5 minutes:
npm install @scout_apm/scout-apm
Add the middleware to your Express or NestJS application, set your API key, and deploy. N+1 detection, error monitoring, and request tracing start automatically.
Try Scout Monitoring for Node.js. Free tier, no credit card required.
For application monitoring with errors and traces, Scout Monitoring provides the fastest path to useful information without the bloat.