Back to articles
Backend Engineering

How to Reduce API Response Time in Node.js: A Production Guide

A practical, measurement-first guide to reducing Node.js API latency with PostgreSQL query tuning, indexes, Redis caching, background jobs, and production observability.

Published August 3, 2026 26 min read

A Node.js endpoint that takes 2.3 seconds in production is rarely slow because JavaScript itself is slow. The time is usually hiding in database round trips, missing indexes, repeated network calls, serialization, or work that should never have been on the request path.

This guide follows a production incident from 2.3 seconds to 180 milliseconds. The numbers are illustrative, but the investigation method, SQL, caching rules, queue design, and monitoring strategy are the same ones you can apply to a real Express, Fastify, or NestJS API.

The goal is not the smallest benchmark number. The goal is a predictable latency budget under realistic traffic, with correct data and observable failure modes.

What “fast” means in production

Average response time hides the requests users complain about. Track percentiles: p50 describes a typical request, p95 catches the slow experience shared by one in twenty requests, and p99 exposes tail latency caused by lock contention, cache misses, garbage collection, or a saturated connection pool.

For an interactive JSON endpoint, a useful starting budget might look like this:

1

Edge and network

Example budget
25 ms
What it includes
TLS, routing, load balancer
2

Authentication

Example budget
10 ms
What it includes
Token verification and policy lookup
3

Application

Example budget
25 ms
What it includes
Validation and business logic
4

Database

Example budget
80 ms
What it includes
Queries, waits, and result transfer
5

Serialization

Example budget
10 ms
What it includes
Mapping and JSON encoding
6

Safety margin

Example budget
30 ms
What it includes
Normal production variance

That creates a 180 ms server-side target. Your numbers will differ, but assigning a budget makes “the API feels slow” testable.

Measure before optimizing

Begin at the outside: record total request duration at the server boundary, then add spans around database queries, Redis calls, downstream HTTP requests, and expensive functions. Use the same request ID across every layer. Measure locally for iteration, but make decisions from production-like data volumes and concurrency.

import { performance } from "node:perf_hooks";

app.use((req, res, next) => {
  const startedAt = performance.now();

  res.on("finish", () => {
    const durationMs = performance.now() - startedAt;
    logger.info({
      requestId: req.id,
      method: req.method,
      route: req.route?.path ?? "unmatched",
      statusCode: res.statusCode,
      durationMs: Math.round(durationMs),
    }, "request completed");
  });

  next();
});

Do not use raw URLs as metric labels; IDs in paths create unbounded cardinality. Prefer normalized routes such as /users/:id/orders.

A trace turns one slow response into a sequence of measurable operations.

In our example trace, application code consumed 40 ms, three external calls consumed 90 ms, and PostgreSQL consumed 1.9 seconds. That evidence changes the plan: rewriting JavaScript would optimize the smallest part of the request.

Reproduce the workload, not just the request

A single request against an empty local database can be misleading. Test with representative row counts, realistic payloads, cache-hit and cache-miss paths, and concurrent connections. Warm up the process before recording results and report p50, p95, p99, throughput, and error rate together.

Eliminate N+1 database queries

The N+1 problem happens when code loads one collection and then executes another query for every row. An endpoint returning 100 orders may quietly issue 101 queries.

// Slow: 1 query for orders, then N queries for customers.
const orders = await db.order.findMany({ where: { accountId } });
const response = await Promise.all(
  orders.map(async (order) => ({
    ...order,
    customer: await db.customer.findUnique({
      where: { id: order.customerId },
    }),
  })),
);

Promise.all makes the code concurrent, but it does not remove the work. It may instead flood the database pool. Fetch the relation with a join/eager load, or collect identifiers and batch them in one query.

const orders = await db.order.findMany({
  where: { accountId },
  include: {
    customer: { select: { id: true, name: true, plan: true } },
  },
  take: 50,
});
Batching removes round trips; limiting selected fields also reduces transfer and serialization work.

Joins are not automatically better. A join across several one-to-many relations can multiply rows and produce a huge intermediate result. Inspect the generated SQL and compare a join with two bounded batch queries. The correct choice minimizes total database work and bytes transferred—not merely query count.

Build indexes from query patterns

Suppose the slow endpoint runs:

SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE account_id = $1
  AND status = 'paid'
ORDER BY created_at DESC
LIMIT 50;

A standalone index on account_id may still force PostgreSQL to filter paid orders and sort them. A composite partial index can match this access pattern:

CREATE INDEX CONCURRENTLY idx_orders_account_paid_created
ON orders (account_id, created_at DESC)
WHERE status = 'paid';

Use EXPLAIN (ANALYZE, BUFFERS) on a safe production-like environment. Look for sequential scans over large tables, estimates that differ sharply from actual rows, large sorts, disk reads, and time waiting on locks. ANALYZE executes the query, so be cautious with writes and expensive statements. PostgreSQL's index documentation is the best reference when comparing multicolumn, partial, covering, and specialized index types.

A useful index follows the endpoint's filters and ordering instead of indexing columns in isolation.

Every index consumes storage and adds work to inserts and updates. Keep indexes tied to known query patterns, remove redundant ones only after checking usage, and create large production indexes concurrently to reduce blocking.

Return less data

Avoid SELECT *. Select only fields used by the response, paginate large collections, and prefer cursor pagination for deep or frequently changing datasets. Offset pagination makes the database walk past discarded rows and can shift results between requests.

SELECT id, total_cents, created_at
FROM orders
WHERE account_id = $1
  AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 50;

The matching stable index is (account_id, created_at DESC, id DESC). The id tie-breaker prevents duplicate or missing rows when timestamps match.

Cache expensive, reusable reads with Redis

After query tuning, our database path fell from 1.9 seconds to 310 ms. Redis reduced frequently repeated reads further—but only because the data had clear freshness rules.

Use cache-aside for read-heavy endpoints:

Redis maintains an official cache-aside guide for Node.js covering TTL selection, invalidation, missing records, and stampede protection. Treat it as a reference implementation, then adapt the freshness policy to your domain.

Build versioned key
Read Redis
Return cache hit
Query PostgreSQL on miss
Store with TTL and jitter
Return response
async function getAccountSummary(accountId: string) {
  const key = `account-summary:v3:${accountId}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const summary = await loadAccountSummary(accountId);
  const ttlSeconds = 300 + Math.floor(Math.random() * 30);
  await redis.set(key, JSON.stringify(summary), { EX: ttlSeconds });
  return summary;
}
Cache-aside keeps PostgreSQL authoritative while Redis serves repeated reads.

The key includes a schema version so deployments can move to a new representation without scanning Redis. Small TTL jitter prevents thousands of popular keys expiring simultaneously.

Prevent stale data and cache stampedes

Choose an invalidation strategy before shipping:

1

Short TTL

Use when
Brief staleness is acceptable
Trade-off
Simple, but repeated misses remain
2

Delete on write

Use when
Writes pass through one service
Trade-off
Fresh reads, coupled invalidation
3

Versioned keys

Use when
Deployments change representation
Trade-off
Old keys expire naturally
4

Single-flight lock

Use when
One miss is very expensive
Trade-off
Requires lock timeout and recovery
5

Stale-while-revalidate

Use when
Availability matters more than perfect freshness
Trade-off
Can serve known-stale data

Never cache authorization decisions without including tenant, user, role/version, and policy version. Never let a global key expose one customer's data to another. Cache failures should normally fall back to the database with rate protection, not take down an otherwise healthy endpoint.

Remove non-critical work from the request path

Sending email, generating PDFs, resizing images, exporting analytics, and calling slow AI providers do not belong in an interactive request when the user does not need the result immediately. Persist intent and enqueue work, then return 202 Accepted with a job ID.

app.post("/reports", async (req, res) => {
  const input = reportSchema.parse(req.body);
  const job = await createReportJob({
    accountId: req.user.accountId,
    input,
    idempotencyKey: req.get("Idempotency-Key"),
  });

  await reportQueue.add("generate", { jobId: job.id }, {
    jobId: job.id,
    attempts: 5,
    backoff: { type: "exponential", delay: 1_000 },
  });

  res.status(202).json({ jobId: job.id, status: "queued" });
});
The API acknowledges durable work quickly; workers process it independently with bounded retries.

Jobs must be idempotent because queues generally provide at-least-once delivery. Record stable states (queued, running, succeeded, failed), cap retries, send permanent failures to a dead-letter path, and make workers safe to restart. For critical workflows, use a transactional outbox so a database commit and its queued event cannot drift apart.

For a deeper implementation of validation, provider timeouts, retries, poison messages, and idempotency, continue with the notification workers lesson.

Tune the Node.js runtime and network path

Database and architecture fixes usually dominate, but the runtime still matters.

Avoid blocking the event loop

Synchronous filesystem calls, large JSON transformations, expensive regular expressions, compression, and CPU-heavy crypto block other requests in the same process. Use worker threads or a job worker for CPU-bound tasks. Track event-loop delay; low CPU does not prove the loop is healthy. Node.js exposes monitorEventLoopDelay through node:perf_hooks for this purpose.

Bound concurrency

Unlimited Promise.all against a dependency can turn one busy request into an outage. Set HTTP timeouts, cap parallel work, and keep the database pool intentionally sized. With many application instances, instances × pool size must fit below the database connection limit with room for migrations and operations.

Reuse connections and compress selectively

Use keep-alive and connection pooling for downstream services. Compress text responses when the bandwidth savings exceed CPU cost, but skip already-compressed formats. A CDN can serve public, cacheable responses near users; personalized responses require correct Cache-Control and Vary headers to prevent data leakage.

Monitor the optimized system

An optimization is incomplete until you can detect its regression. Build dashboards around the four signals that explain user impact: latency, traffic, errors, and saturation.

Correlate endpoint latency with database, Redis, runtime, and capacity signals.
HTTP
p50/p95/p99 by normalized route, throughput, status codes, payload size
PostgreSQL
query duration, pool wait time, active connections, locks, rows scanned
Redis
hit rate, latency, evictions, memory pressure, connection errors
Node.js
event-loop delay, heap, garbage collection pauses, CPU, restarts
Queues
depth, oldest-job age, processing time, retries, dead-letter count

Alert on symptoms users feel and conditions that predict them. For example: p95 latency above the service objective for ten minutes, database pool wait above 50 ms, or queue age exceeding the promised completion window. A cache hit-rate alert alone is noisy unless a falling hit rate also threatens latency or database capacity.

The monitoring and observability lesson shows how to turn queue age, provider latency, retry counts, and dead-letter volume into an actionable production dashboard.

The performance result

The final improvement came from several evidence-backed changes, not one trick:

1

N+1 query removal

Before
101 queries
After
2 bounded queries
2

Composite index

Before
Sequential scan + sort
After
Indexed lookup
3

Response shape

Before
Large nested payload
After
Selected, paginated fields
4

Redis cache

Before
Every read hits PostgreSQL
After
Hot reads served from cache
5

Report generation

Before
Inside HTTP request
After
Idempotent background job
6

p95 response time

Before
2.3 seconds
After
180 milliseconds
A measurement-first optimization reduced p95 latency while preserving correctness and operational visibility.

Validate before-and-after results with the same dataset, traffic profile, environment, and cache state. Also compare error rate, database CPU, memory, and cost. A lower p50 that produces timeouts at p99 is not a successful optimization.

A repeatable optimization checklist

  1. Define a latency objective and a per-stage budget.
  2. Capture p50, p95, p99, throughput, and errors before changing code.
  3. Add request IDs and spans across database, cache, queues, and HTTP dependencies.
  4. Remove N+1 access and fetch only required fields.
  5. inspect query plans and add indexes that match real filters and ordering.
  6. Paginate large collections with stable cursors.
  7. Cache only reusable reads with explicit freshness and tenant-safe keys.
  8. Protect hot cache misses from stampedes.
  9. Move slow, non-critical work to idempotent background jobs.
  10. Bound concurrency, connection pools, retries, and timeouts.
  11. Load-test the complete system with production-like data.
  12. Deploy gradually and watch both latency and saturation.

Final thoughts

Fast Node.js APIs come from controlling the work behind each request. Measure the complete path, optimize the database before micro-tuning JavaScript, cache with deliberate correctness rules, and remove asynchronous work from synchronous endpoints.

For a newer developer, the most valuable habit is simple: inspect the evidence before guessing. For an experienced engineer, the harder discipline is protecting the result with budgets, percentiles, idempotency, capacity limits, and observability. Together, those practices turn a quick endpoint into a production system that stays quick.

If your slow endpoint is part of an AI workflow, the related guide to building production-ready AI features in Next.js applies the same caching, queue, and observability principles to streaming and model-backed workloads.

#Node.js#API Performance#PostgreSQL#Redis#Observability