APILensAPILens
All posts
Node.jsPerformanceAPIOptimization

How to Reduce API Response Time in Node.js — A Practical Guide

Slow APIs kill conversion rates, frustrate users, and waste infrastructure spend. This guide walks through measuring, diagnosing, and fixing slow response times in Node.js APIs — with real examples.

Rahul Patel·10 min read·April 15, 2026

Why API Response Time Matters More Than You Think

A 100ms delay in API response time doesn't sound like much. But consider the chain: a frontend makes 5–10 API calls to render a page. If each call is 100ms slower than it should be, your user waits 500–1000ms longer than necessary. Studies consistently show that every 100ms of latency reduces conversion rates by ~1%.

More practically: slow APIs frustrate developers who integrate with you, trigger timeout errors in production, and — if you're billing per-seat or usage — directly affect your NPS scores.

The good news: most Node.js API latency problems come from a small number of root causes, and they're fixable once you can see them.


Step 0: Measure Before You Optimize

The biggest mistake when optimizing API performance is guessing where the bottleneck is. The second-biggest is measuring in development, where your DB has 10 rows and no network latency.

You need real production data.

npm install auto-api-observe
const { observe } = require('auto-api-observe');
app.use(observe({ apiKey: process.env.APILENS_KEY }));

After a few hours of real traffic, go to your dashboard and look at the Routes tab. Sort by p95 latency. The answer to "which route is slow" is immediately visible — you don't need to guess or run load tests.

A typical first look reveals:

- 80% of routes are fast (< 100ms p95)

- 15% are medium (100–500ms p95)

- 5% are problematic (> 500ms p95) — and those 5% are usually responsible for most user complaints

Fix the 5% first. Don't optimize routes that are already fast.


The 5 Most Common Causes of Slow Node.js APIs

1. N+1 Database Queries

The most common performance killer. You fetch a list, then loop over it making individual queries.

// ❌ This fires 1 + N queries
const posts = await Post.find({}).limit(20);
for (const post of posts) {
  post.author = await User.findById(post.userId); // N queries
}

Fix: Use JOIN or populate with a single query.

// ✓ 1 query total
const posts = await Post.find({}).populate('userId').limit(20);

The APILens dashboard flags routes with N+1 automatically — look for routes with high DB query counts (> 5 queries per request is a red flag).

2. Missing Database Indexes

A query that scans 10 rows returns in microseconds. The same query scanning 1 million rows takes seconds. If your query is slow and it's not N+1, it's usually a missing index.

-- Check query execution plan
EXPLAIN ANALYZE SELECT * FROM events WHERE user_id = 123 AND created_at > NOW() - INTERVAL '7 days';

If you see "Seq Scan" instead of "Index Scan", you need an index:

CREATE INDEX idx_events_user_created ON events(user_id, created_at DESC);

3. Synchronous/Blocking Operations in the Event Loop

Node.js is single-threaded. Anything that blocks the event loop blocks all requests.

// ❌ Blocks the event loop — no other requests can be handled during this
app.get('/export', (req, res) => {
  const data = processLargeDataset(millionRows); // synchronous, takes 2s
  res.json(data);
});

Fix: Move CPU-heavy work to a worker thread or a background job queue.

// ✓ Non-blocking
const { Worker } = require('worker_threads');

app.get('/export', async (req, res) => {
  const jobId = await queue.add('processExport', { userId: req.user.id });
  res.json({ jobId, status: 'processing' });
});

4. No Connection Pooling

Opening a new database connection per request adds 20–100ms of overhead. Always use a connection pool.

// ❌ New connection every request
app.get('/users', async (req, res) => {
  const client = new pg.Client(config);
  await client.connect(); // expensive
  const result = await client.query('SELECT * FROM users');
  await client.end();
  res.json(result.rows);
});

// ✓ Pool shared across all requests
const pool = new pg.Pool({ max: 20, ...config });

app.get('/users', async (req, res) => {
  const result = await pool.query('SELECT * FROM users');
  res.json(result.rows);
});

5. Over-fetching Data

Selecting columns you don't use adds serialization time and network overhead.

// ❌ Fetches 40 columns, uses 3
const user = await User.findById(id); // SELECT * FROM users WHERE id = ?

// ✓ Fetch only what you need
const user = await User.findById(id).select('name email avatarUrl');
// SELECT name, email, avatar_url FROM users WHERE id = ?

Step 2: Find Your Slowest Routes in Production

After a day of traffic with monitoring enabled, here's what to look at:

Routes tab → sort by p95 latency

p95 latency means 95% of requests to that route complete within this time. A p95 of 800ms means 1 in 20 users waits over 800ms. That's your target.

Database tab → query count per route

Routes with > 5 queries/request almost always have an N+1 problem. Routes with 1–2 queries/request but high latency usually have a missing index or large result set.

Slow tab

Routes that exceeded your slow threshold. By default, 500ms. I recommend setting this to 300ms for user-facing APIs.


Step 3: A Worked Example

Let's say your dashboard shows GET /api/timeline has p95 of 1.8s and 34 DB queries per request.

Diagnosis: N+1. The timeline fetches 30 posts and queries for each post's author, likes, and comment count individually.

Before fix:

app.get('/api/timeline', async (req, res) => {
  const posts = await db.query('SELECT * FROM posts ORDER BY created_at DESC LIMIT 30');

  for (const post of posts) {
    post.author   = await db.query('SELECT name, avatar FROM users WHERE id = $1', [post.user_id]);
    post.likes    = await db.query('SELECT COUNT(*) FROM likes WHERE post_id = $1', [post.id]);
    post.comments = await db.query('SELECT COUNT(*) FROM comments WHERE post_id = $1', [post.id]);
  }
  // Total: 1 + 30 + 30 + 30 = 91 queries

  res.json(posts);
});

After fix:

app.get('/api/timeline', async (req, res) => {
  const posts = await db.query(`
    SELECT
      p.*,
      u.name AS author_name,
      u.avatar AS author_avatar,
      COUNT(DISTINCT l.id) AS likes_count,
      COUNT(DISTINCT c.id) AS comments_count
    FROM posts p
    JOIN users u ON u.id = p.user_id
    LEFT JOIN likes l ON l.post_id = p.id
    LEFT JOIN comments c ON c.post_id = p.id
    GROUP BY p.id, u.name, u.avatar
    ORDER BY p.created_at DESC
    LIMIT 30
  `);
  // Total: 1 query

  res.json(posts);
});

Result: p95 drops from 1.8s to 85ms. 34 queries → 1 query.


Step 4: Add Caching for Stable, Expensive Results

Some data doesn't change often. Cache it.

const redis = require('ioredis');
const client = new redis();

app.get('/api/leaderboard', async (req, res) => {
  const cached = await client.get('leaderboard');
  if (cached) return res.json(JSON.parse(cached));

  const data = await db.query(`
    SELECT user_id, SUM(score) as total FROM scores GROUP BY user_id ORDER BY total DESC LIMIT 10
  `);

  await client.setex('leaderboard', 60, JSON.stringify(data.rows)); // 60s TTL
  res.json(data.rows);
});

Use caching when:

- Data is expensive to compute (aggregations, complex joins)

- Data freshness of 30–120 seconds is acceptable

- The same result is returned to many users

Don't cache: per-user personalized data, real-time balances, anything where stale data causes user harm.


What Results to Expect

Based on common optimization patterns:

OptimizationTypical Latency Improvement
Fix N+1 (10 queries → 1)60–90% reduction
Add missing DB index80–99% reduction for affected queries
Switch to connection pooling20–80ms saved per request
Cache expensive aggregation90%+ reduction
Select only needed columns5–30% reduction

The biggest wins are almost always in database access patterns. Optimize there first.


Summary

The fastest path to a faster Node.js API:

1. Measure in production — add auto-api-observe middleware, let it collect real traffic data

2. Find the worst 5% of routes — sort by p95 latency in the dashboard

3. Check DB query count — routes with > 5 queries/request almost certainly have N+1

4. Fix the root cause — JOIN instead of loop, add indexes, use connection pooling

5. Verify the fix — the dashboard shows before/after immediately in production

- Install: npm install auto-api-observe

- Dashboard: apilens.rest/dashboard

- GitHub: github.com/rahhuul/auto-api-observe

Free during beta. No credit card.