When a Node.js API gets slow, the cause is rarely Node.js itself. The usual problems are familiar: work that blocks the event loop, database access patterns that do not scale, missing limits on what a request can ask for, and outbound calls that are allowed to hang.
This guide walks through those problems, how to recognize them and how to fix them. The first rule is the same for all of them: measure before you change anything. Performance work based on guesses tends to optimize code that wasn't the bottleneck.
Measure first
Before touching code, get enough visibility to answer three questions: which endpoints are slow, how slow (look at p95 and p99, not just averages), and where the time goes inside a slow request.
- APM tooling: an application performance monitoring service (Datadog, New Relic, Elastic APM, or OpenTelemetry with a backend of your choice) shows per-endpoint latency and breaks a request into database calls, HTTP calls and application time.
- Structured logs with timings: at minimum, log method, route, status code and duration for every request, plus the duration of each database query and external call, with a request ID that ties them together.
- Event loop metrics: track event loop lag or delay (Node exposes
monitorEventLoopDelayinperf_hooks). Rising lag under load is a strong sign that something is blocking. - Profilers:
clinic doctorandclinic flamefrom clinic.js, ornode --profandnode --cpu-prof, show where CPU time goes in a reproducible load test. - Query plans: for slow SQL, run
EXPLAIN ANALYZEon the actual query with realistic data to see whether it uses indexes and how many rows it touches.
Blocking the event loop
Node runs your JavaScript on a single thread. While one request is doing synchronous CPU work, every other request on that process waits. The symptom is distinctive: latency rises across all endpoints at once, even simple ones, and event loop lag spikes.
Common causes include:
- Synchronous APIs such as
crypto.pbkdf2Sync,bcrypt.hashSync,fs.readFileSyncorzlib.gzipSyncinside request handlers. Use the async versions. - JSON.parse or JSON.stringify on large payloads. Serializing a multi-megabyte response blocks the thread for the entire duration. Return less data, paginate, or stream.
- CPU-heavy work in the request path: PDF generation, image resizing, large CSV parsing, report calculations, or expensive regular expressions.
Fixes depend on whether the caller needs the result immediately. For short CPU tasks, move them to worker threads (a pool library like Piscina makes this manageable). For anything that can be asynchronous from the user's point of view, put a job on a queue (BullMQ with Redis, SQS or similar), return a 202 with a job ID, and process it in a separate worker process.
Database access patterns
Most API latency lives in the database. Three patterns account for a large share of it.
N+1 queries
Fetching a list of 50 orders and then running a separate query for each order's customer results in 51 round trips. Each one is fast on its own, so the problem is easy to miss in development and painful in production. Look for queries inside loops, or ORM lazy loading. Fix it with a join, an IN (...) query that loads related rows in one go, your ORM's eager-loading option, or a batching layer like DataLoader for GraphQL APIs.
Missing indexes
If EXPLAIN ANALYZE shows a sequential scan on a large table for a query that filters or sorts on specific columns, you likely need an index. Composite indexes should match how the query filters and sorts, for example (tenant_id, created_at) to fetch a tenant's most recent records. Do not add indexes blindly; each one slows writes and uses storage.
Unbounded queries
An endpoint that returns "all records" works fine until a customer has a lot of them. Every list endpoint should have a default and a maximum page size. For large or frequently changing datasets, cursor-based pagination (for example, "items after this ID") performs better than OFFSET, which gets slower the deeper you page.
Connection pooling and pool exhaustion
Opening a new database connection per request adds latency and can overwhelm the database. Use a connection pool, created once at startup and shared across the process.
Pools have their own failure mode: exhaustion. If requests hold connections too long (slow queries, long transactions, or a transaction that waits on an external HTTP call), new requests queue for a connection and latency climbs even though the database itself looks idle. Watch pool metrics such as active, idle and waiting counts. Keep transactions short, never make network calls inside them, and size pools with your total process count in mind: ten instances with a pool of 20 each means up to 200 connections against the database. In serverless or heavily scaled environments, a proxy like PgBouncer or RDS Proxy is often necessary.
Caching and cache invalidation
If the same expensive data is requested repeatedly and changes rarely, caching can remove most of the load. Good candidates are reference data, configuration, computed aggregates and responses from slow third-party APIs.
- Pick the right layer: in-process memory is fastest but not shared across instances; Redis is shared and survives restarts; HTTP caching headers and a CDN help for public, cacheable responses.
- Plan invalidation up front. Either use short TTLs and accept slightly stale data, or explicitly delete or update cache entries when the underlying data changes. Mixing the two without a clear rule is how stale-data bugs happen.
- Include the tenant in cache keys in multi-tenant systems, so one customer never sees another's cached data.
- Guard against stampedes: when a popular entry expires, many requests may try to rebuild it at once. Request coalescing or a short lock prevents that.
Third-party calls, memory and payload size
Chatty or unbounded external calls
Calls to payment providers, CRMs or internal services are a frequent source of slow requests. Many HTTP clients have no timeout by default, so a slow upstream can hold your request open indefinitely. Set explicit timeouts on every outbound call (with native fetch, AbortSignal.timeout(5000) is a simple option), retry only idempotent requests with backoff, and use a circuit breaker for dependencies that fail often. Run independent calls in parallel with Promise.all rather than one after another, and batch requests where the API supports it. If a call does not need to finish before you respond, move it to a queue. Our API integration work deals with these patterns constantly.
Memory leaks
A process whose memory grows steadily until it is restarted or killed usually has a leak. Common culprits are unbounded in-memory caches or maps, event listeners added per request and never removed, and closures holding references to large objects. Compare heap snapshots taken a few minutes apart under load (Chrome DevTools or clinic heapprofiler) to see which objects keep accumulating. Use a bounded LRU cache instead of a plain object or Map.
Large payloads
Returning fields the client never uses costs serialization time, bandwidth and memory. Select only the columns you need, let clients request sparse fields where it makes sense, and enable gzip or Brotli compression, preferably at the load balancer or reverse proxy rather than in Node. For large exports, stream the response instead of building it in memory.
Next step
If your API is slowing down as usage grows and you want an experienced set of eyes on it, our Node.js development team can profile it, identify the real bottlenecks and fix them. For infrastructure-level issues like scaling, pooling proxies and observability, see our cloud and DevOps services, or contact us to describe what you are seeing.