JavaScript

What I Learned Debugging 5 Slow Node.js Patterns

My Node app was fast yesterday and crawling today. Here are the 5 real Node.js performance patterns I debugged, blocking event loops, N+1 queries, and more.

R
Ruhan Ahmed
Oct 21, 2026
12 min read
Table of Contents
What I Learned Debugging 5 Slow Node.js Patterns

Your Node app was fast yesterday. Today it's crawling. Nothing changed. So what happened?

I've been there. Too many times. And after enough late nights, I started seeing the same five things over and over. Different bugs, same root causes. This isn't some abstract best-practices list. These are the actual patterns I've debugged. The ones that made me want to throw my laptop out the window. And the fixes that finally worked.

Let's start with the worst one.

Read article on "How We Cut API Response Time by 60%"

The Event Loop Is a One-Lane Road. And You Just Parked a Truck in It.

Here's the thing about Node that took me way too long to get: JavaScript runs on one thread. One thread handles everything. Every request, every callback, every timer. Block it, and you block it all.

Look at this innocent function:

javascript

function badTask() {
  const start = Date.now();
  while (Date.now() - start < 3000) {
    // keep CPU busy for 3 seconds
  }
  console.log("Heavy task finished!");
}

You might think, "It's just 3 seconds. What's the big deal?"

The big deal is that during those 3 seconds, nothing else happens. No health check. No login. No simple ping. Everything waits in line behind your loop.

One team put it bluntly: "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." Now multiply that by 3 seconds.

This Happens More Than You Think

The tricky part? Blocking code doesn't always look like a while loop. It hides as:

  • Sync file reads (fs.readFileSync) inside a route

  • Big JSON serialization (JSON.stringify on a 50MB object)

  • Bad regex with catastrophic backtracking

  • Sync crypto like pbkdf2 or randomBytes

  • Deep cloning with JSON.parse(JSON.stringify(data))

I once spent two hours chasing a "slow API" that turned out to be one fs.readFileSync call in some middleware I added months ago and forgot about.

The Fix: Move It Off the Main Thread

The Node docs are clear. The event loop should stay free. If a task takes more than a few milliseconds, it doesn't belong there.

For CPU-heavy work, use Worker Threads:

javascript

const { Worker } = require('worker_threads');

app.get('/process', (req, res) => {
  const worker = new Worker('./heavy-task.js', {
    workerData: { input: req.body }
  });
  
  worker.on('message', (result) => res.json(result));
  worker.on('error', (err) => res.status(500).json({ error: err.message }));
});

For tasks that can wait, push them to a background job queue (BullMQ, Bee-Queue, or even just a DB table plus a worker).

Common mistake: wrapping every async call in a Worker. That's overkill. Your DB driver, HTTP client, and most SDKs are already non-blocking. Only move stuff that actually burns CPU.

Read article on "10 Next.js Security Mistakes"

The N+1 Query Problem (Or: Why Your Loop Is Lying to You)

This one is sneaky. It looks totally fine in dev.

javascript

const users = [1, 2, 3, 4, 5];

async function slowDatabaseExample() {
  for (const user of users) {
    await fakeDatabaseQuery(user);
  }
}

With 5 users, this runs in milliseconds. With 500 users in prod, you just fired 500 queries. One after another. Each one waits for the last.

And the total time isn't just 500 × query_time. It's worse, because you're also paying network round-trip latency 500 times.

One monitoring guide nailed why this feels natural in JS: "The 'loop and query' pattern feels natural in JavaScript." We loop over arrays all day. But every await inside that loop is a full round trip to your DB.

The Fix: Batch or Parallelize (Carefully)

The easiest improvement is Promise.all:

javascript

async function fastDatabaseExample() {
  await Promise.all(users.map(fakeDatabaseQuery));
}

Now all queries fire at once. But here's the trap: concurrency isn't free. If you have 500 users, you just opened 500 DB connections at the same time. Your connection pool (usually 10–20 connections) gets slammed. Now every other request in your app is waiting for a connection.

The real fix depends on your setup:

Option A: One query instead of many

javascript

const users = await db.query('SELECT * FROM users WHERE id IN (?)', [ids]);

Option B: Batch it

If you're on GraphQL, DataLoader is the standard. For REST, you can use something like microbatch to collect individual calls and fire them as one query:

javascript

const userLoader = batcher(async (ids) => {
  const users = await db.query('SELECT * FROM users WHERE id IN (?)', [ids]);
  return new Map(users.map(u => [u.id, u]));
});

const alice = await userLoader.load(1);
const bob = await userLoader.load(2);

Option C: Limit concurrency

If batching isn't possible, cap how many queries run at once with something like p-limit:

javascript

const pLimit = require('p-limit');
const limit = pLimit(10); // max 10 at a time

await Promise.all(users.map(user => 
  limit(() => fakeDatabaseQuery(user))
));

Common mistake: thinking Promise.all is always faster. It fires everything at once. If your DB or pool can't take the burst, you just traded one bottleneck for another.

Read article on "HTTP/3 vs HTTP/2"

Loading Everything (Because "Just in Case" Is Expensive)

This is the pattern I see most in codebases that "worked fine at first."

javascript

app.get('/api/orders', async (req, res) => {
  const orders = await db.query('SELECT * FROM orders');
  res.json(orders);
});

Works fine. Until your orders table hits 100,000 rows. Now every request loads 100k records into memory, turns them into JSON, and shoves them over the network. Memory spikes. Response takes 8 seconds. Client times out and retries, making it worse.

I've watched one unpaginated endpoint bring down a whole API because garbage collection couldn't keep up.

The Fix: Paginate and Select Only What You Need

Pagination is step one. Offset works fine for a lot of cases:

javascript

app.get('/api/orders', async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = Math.min(parseInt(req.query.limit) || 20, 100);
  const offset = (page - 1) * limit;
  
  const orders = await db.query(
    'SELECT id, customer_name, total, created_at FROM orders LIMIT ? OFFSET ?',
    [limit, offset]
  );
  
  res.json({ data: orders, page, limit });
});

But offset pagination has a cost. The DB still scans all the rows you're skipping. For big tables, cursor-based pagination is better:

javascript

app.get('/api/orders', async (req, res) => {
  const limit = 20;
  const cursor = req.query.cursor;
  
  let query = 'SELECT id, customer_name, total FROM orders ORDER BY id DESC LIMIT ?';
  const params = [limit + 1];
  
  if (cursor) {
    query = 'SELECT id, customer_name, total FROM orders WHERE id < ? ORDER BY id DESC LIMIT ?';
    params.unshift(cursor);
  }
  
  const orders = await db.query(query, params);
  const hasNext = orders.length > limit;
  const results = hasNext ? orders.slice(0, -1) : orders;
  
  res.json({
    data: results,
    nextCursor: hasNext ? results[results.length - 1].id : null
  });
});

The other half of the fix: pick only the columns you need. SELECT * is easy, but it forces the DB to read every column. On a table with a big TEXT column for notes, that's a lot of wasted work.

Common mistake: paginating the API but not the DB query. If your ORM loads everything and then slices the array, you saved network bandwidth but not memory or DB load.

Read article on "10 High-Impact Projects"

Recalculating the Same Thing Over and Over (Because Nobody Told You to Cache)

Some values barely change. But if your code recalculates them every request, you're burning CPU for nothing.

I've seen this with:

  • Config lookups (reading the same row 1,000 times an hour)

  • Computed values (currency, aggregates)

  • External API calls (exchange rates on every request)

  • Expensive joins that always produce the same result

The fix is stupid simple. Which is probably why people forget it.

The Fix: Cache What's Safe to Reuse

For in-memory caching, a simple Map with a TTL does the job:

javascript

class MemoryCache {
  constructor() {
    this.cache = new Map();
  }
  
  set(key, value, ttlSeconds = 300) {
    this.cache.set(key, {
      value,
      expires: Date.now() + ttlSeconds * 1000
    });
  }
  
  get(key) {
    const item = this.cache.get(key);
    if (!item) return null;
    if (Date.now() > item.expires) {
      this.cache.delete(key);
      return null;
    }
    return item.value;
  }
}

const cache = new MemoryCache();

app.get('/api/config', async (req, res) => {
  const cached = cache.get('app-config');
  if (cached) return res.json(cached);
  
  const config = await db.query('SELECT * FROM config');
  cache.set('app-config', config, 600); // 10 min
  res.json(config);
});

For multiple servers, use Redis:

javascript

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

async function getConfig() {
  const cached = await redis.get('app-config');
  if (cached) return JSON.parse(cached);
  
  const config = await db.query('SELECT * FROM config');
  await redis.setex('app-config', 600, JSON.stringify(config));
  return config;
}

The Traps Nobody Warns You About

Trap 1: Caching user data under a shared key. I once cached a dashboard response under 'dashboard' and spent an hour wondering why every user saw the same data.

Trap 2: Never invalidating. Cache something for 24h, then update it. Users see stale data all day. Keep TTLs short for stuff that changes, or invalidate on write.

Trap 3: Unbounded in-memory caches. A Map that grows forever is a memory leak. If you're caching sessions or search results, you need TTL or LRU eviction.

Caching is one of the best performance wins you can get. But only if you're honest about what's safe to cache.

Read article on "How to Build a TDD Workflow "

The Hidden Culprit: Connection Pool Saturation

This one isn't in the original script, but it's the pattern hiding behind all the others.

When your N+1 code fires 500 concurrent queries, or your unpaginated endpoint takes 10 seconds to serialize, your database connection pool becomes the bottleneck.

Here's how it works. Your app shares a small pool of (usually) 10–20 DB connections across all requests. When the pool's empty, new queries wait in line. The waiting itself is async (the event loop isn't blocked), but every request that needs a DB connection is now slower.

The symptom looks like "the DB is slow." But the DB might be fine. It's your app's hunger for connections that's the problem.

How to Spot It

Watch pool usage. If it's consistently over 70–80%, you're heading for trouble. Over 85%, you're one traffic spike away from cascading delays.

javascript

setInterval(() => {
  const pool = db.pool; // depends on your driver
  console.log({
    total: pool.totalCount,
    idle: pool.idleCount,
    waiting: pool.waitingCount
  });
}, 10000);

The Fix: Reduce Demand Before Adding Supply

Sure, you can bump the pool size. But that's treating the symptom. The real fix is fewer queries per request:

  1. Batch queries (the N+1 fix)

  2. Cache frequently-read data

  3. Paginate big result sets

  4. Move non-critical queries to background jobs

Only after those should you think about a bigger pool. And only if your DB can actually handle more connections.

Read article on "What to Know Before Learning Next.js"

Before and After: Real Numbers

Metric

Before

After

p95 response time

4.2s

180ms

Event loop lag (p95)

850ms

12ms

Queries per request

47 avg

6 avg

Memory

1.8GB (growing)

420MB (stable)

Pool waiting

34 requests

0

These numbers aren't from one magic fix. They're from applying all five over a couple weeks. The event loop fix killed the worst spikes. The N+1 fix cut query volume by 80%. Pagination stopped the memory growth. Caching took care of the rest.

Summary

Node isn't slow. It's just honest about its limits. It gives you one thread and expects you to use it wisely. Block that thread, and everyone feels it. Fire 500 queries in a loop, and your pool pays the price. Load everything, and memory becomes the bottleneck. The event loop is the heart of your app. Keep it free. Keep it light. And it'll handle requests smoothly, even under load.

The Node docs say it best: the event loop is what lets Node do non-blocking I/O "despite the fact that JavaScript is single-threaded." That's the magic. But it only works if you respect the thread you've got.

Quick Reference: The 5 Patterns and Their Fixes

Pattern

Symptom

Fix

Blocking the event loop

Everything freezes during heavy tasks

Worker Threads, background jobs

N+1 queries in a loop

Slow responses as data grows

Batch queries, limited Promise.all, DataLoader

Loading all data

High memory, slow serialization

Pagination (cursor for scale), select only needed columns

Recalculating repeatedly

CPU spikes for identical work

In-memory cache with TTL, Redis for multi-server

Connection pool saturation

"DB is slow" but it isn't

Fewer queries, caching, monitor pool usage

FAQ

What's the single biggest fix?

The event loop one. One 3-second sync task can drag down every request in your app. Moving it to a Worker or background job is usually the highest-leverage change you can make.

Should I always use Promise.all instead of a for loop?

No. Promise.all fires everything at once. 10 items? Fine. 500 items? You'll slam your pool and maybe your DB. Use a concurrency limit or batch into one query.

How do I know if my event loop is blocked?

Watch event loop lag. Healthy apps stay under 10ms. If you see sustained lag over 50ms, something's blocking. Tools like clinic.js doctor can help find it.

Is in-memory caching safe?

For one server, yes, with TTLs and no shared keys for user data. For multiple servers, use Redis. And always think about invalidation before you add caching.

Why does my app get slower as the DB grows?

Because you're probably loading more data than you need. Pagination and picking only needed columns fixes this. If you fetch 10,000 rows when the client shows 20, you're wasting 99.8% of the work.

Different problems, but they stack. N+1 saturates your pool, so every request waits longer. Try to fix it with unbounded Promise.all, and you might create memory pressure that triggers GC pauses, which do block the event loop. The fixes need to work together.

Ever debugged a Node performance issue that turned out to be one of these? Or something totally different? Comments are open.

Tags

# node.js performance# event loop blocking# n+1 query problem# node.js caching# database connection pool# node.js optimization# worker threads
Keep Reading

Related Articles

Continue your learning journey