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.
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:
| Stage | Example budget | What it includes |
|---|---|---|
| Edge and network | 25 ms | TLS, routing, load balancer |
| Authentication | 10 ms | Token verification and policy lookup |
| Application | 25 ms | Validation and business logic |
| Database | 80 ms | Queries, waits, and result transfer |
| Serialization | 10 ms | Mapping and JSON encoding |
| Safety margin | 30 ms | Normal production variance |
Edge and network
- Example budget
- 25 ms
- What it includes
- TLS, routing, load balancer
Authentication
- Example budget
- 10 ms
- What it includes
- Token verification and policy lookup
Application
- Example budget
- 25 ms
- What it includes
- Validation and business logic
Database
- Example budget
- 80 ms
- What it includes
- Queries, waits, and result transfer
Serialization
- Example budget
- 10 ms
- What it includes
- Mapping and JSON encoding
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.
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,
});
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.
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.
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;
}
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:
| Strategy | Use when | Trade-off |
|---|---|---|
| Short TTL | Brief staleness is acceptable | Simple, but repeated misses remain |
| Delete on write | Writes pass through one service | Fresh reads, coupled invalidation |
| Versioned keys | Deployments change representation | Old keys expire naturally |
| Single-flight lock | One miss is very expensive | Requires lock timeout and recovery |
| Stale-while-revalidate | Availability matters more than perfect freshness | Can serve known-stale data |
Short TTL
- Use when
- Brief staleness is acceptable
- Trade-off
- Simple, but repeated misses remain
Delete on write
- Use when
- Writes pass through one service
- Trade-off
- Fresh reads, coupled invalidation
Versioned keys
- Use when
- Deployments change representation
- Trade-off
- Old keys expire naturally
Single-flight lock
- Use when
- One miss is very expensive
- Trade-off
- Requires lock timeout and recovery
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" });
});
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.
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:
| Change | Before | After |
|---|---|---|
| N+1 query removal | 101 queries | 2 bounded queries |
| Composite index | Sequential scan + sort | Indexed lookup |
| Response shape | Large nested payload | Selected, paginated fields |
| Redis cache | Every read hits PostgreSQL | Hot reads served from cache |
| Report generation | Inside HTTP request | Idempotent background job |
| p95 response time | 2.3 seconds | 180 milliseconds |
N+1 query removal
- Before
- 101 queries
- After
- 2 bounded queries
Composite index
- Before
- Sequential scan + sort
- After
- Indexed lookup
Response shape
- Before
- Large nested payload
- After
- Selected, paginated fields
Redis cache
- Before
- Every read hits PostgreSQL
- After
- Hot reads served from cache
Report generation
- Before
- Inside HTTP request
- After
- Idempotent background job
p95 response time
- Before
- 2.3 seconds
- After
- 180 milliseconds
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
- Define a latency objective and a per-stage budget.
- Capture p50, p95, p99, throughput, and errors before changing code.
- Add request IDs and spans across database, cache, queues, and HTTP dependencies.
- Remove N+1 access and fetch only required fields.
- inspect query plans and add indexes that match real filters and ordering.
- Paginate large collections with stable cursors.
- Cache only reusable reads with explicit freshness and tenant-safe keys.
- Protect hot cache misses from stampedes.
- Move slow, non-critical work to idempotent background jobs.
- Bound concurrency, connection pools, retries, and timeouts.
- Load-test the complete system with production-like data.
- 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.