APILensAPILens
All posts
Node.jsDatabasePerformanceExpress

How to Detect and Fix N+1 Queries in Node.js — The Complete Guide

N+1 queries silently kill your API performance. Learn what causes them, how to detect them manually, and how to auto-detect them with one line of middleware.

Rahul Patel·8 min read·March 30, 2026

What is the N+1 Query Problem?

The N+1 query problem is one of the most common performance killers in web APIs. It happens when your code executes 1 query to fetch a list of items, then N additional queries to fetch related data for each item.

Example: You want to show 50 orders with their product names.

// ❌ BAD: N+1 — 51 queries total
app.get('/api/orders', async (req, res) => {
  const orders = await db.query('SELECT * FROM orders LIMIT 50');  // 1 query

  for (const order of orders) {
    order.product = await db.query(                                 // 50 queries
      'SELECT name FROM products WHERE id = $1',
      [order.product_id]
    );
  }

  res.json(orders);
});

This makes 51 database queries instead of 2. At 50 orders it's slow. At 500 orders, your API is dead.


Why N+1 Queries Are So Dangerous

The scary part? You won't notice them during development. With 5 test records, 6 queries take 10ms. In production with 500 records, those 501 queries take 5+ seconds.

RecordsQueriesApprox Time
56~10ms
5051~200ms
500501~3-5 seconds
50005001~30+ seconds (timeout)

N+1 queries are the #1 reason APIs slow down as data grows.


How N+1 Happens in Common ORMs

Prisma

// ❌ N+1: Prisma executes separate queries for each user's posts
const users = await prisma.user.findMany();
for (const user of users) {
  user.posts = await prisma.post.findMany({ where: { authorId: user.id } });
}

// ✅ Fixed: Use include (eager loading)
const users = await prisma.user.findMany({
  include: { posts: true }
});

Sequelize

// ❌ N+1: Lazy loading each order's items
const orders = await Order.findAll();
for (const order of orders) {
  order.items = await order.getItems();
}

// ✅ Fixed: Eager loading with include
const orders = await Order.findAll({
  include: [{ model: Item }]
});

Mongoose

// ❌ N+1: Populating one by one
const orders = await Order.find();
for (const order of orders) {
  await order.populate('product');
}

// ✅ Fixed: Populate in the query
const orders = await Order.find().populate('product');

Raw SQL (pg / mysql2)

// ❌ N+1: Loop queries
const orders = await db.query('SELECT * FROM orders');
for (const order of orders) {
  const [product] = await db.query('SELECT * FROM products WHERE id = $1', [order.product_id]);
  order.product = product;
}

// ✅ Fixed: JOIN or IN clause
const orders = await db.query(\`
  SELECT o.*, p.name as product_name
  FROM orders o
  JOIN products p ON p.id = o.product_id
\`);

How to Detect N+1 Queries — The Manual Way

Method 1: Count queries with a wrapper

let queryCount = 0;
const originalQuery = db.query.bind(db);
db.query = async (...args) => {
  queryCount++;
  return originalQuery(...args);
};

// After the request
console.log('Total queries:', queryCount);  // If this is 51 for 50 records... N+1!

Problems with this approach:

- You have to add/remove this code manually

- It doesn't tell you WHICH route has the problem

- No per-request isolation (concurrent requests mix counts)

- Doesn't work across async boundaries

Method 2: Database query logging

Enable query logging in your ORM:

// Prisma
const prisma = new PrismaClient({ log: ['query'] });

// Sequelize
const sequelize = new Sequelize({ logging: console.log });

// Mongoose
mongoose.set('debug', true);

Problems:

- Floods your console with every query

- No grouping by request

- No automatic "this looks like N+1" detection

- You have to manually count repeated patterns


The Better Way: Auto-Detect N+1 with One Line

What if your middleware could automatically track every database query per request, count them, and flag routes with suspiciously high query counts?

That's exactly what auto-api-observe does.

Install

npm install auto-api-observe

Add one line

const express = require('express');
const observe = require('auto-api-observe');

const app = express();
app.use(observe());  // That's it

What happens automatically

The middleware patches your database libraries (pg, mysql2, mongoose, prisma, knex, sequelize, ioredis, better-sqlite3) at the prototype level. For every request, you get:

{
  "method": "GET",
  "route": "/api/orders",
  "status": 200,
  "latency": 1850,
  "dbCalls": {
    "calls": 51,
    "totalTime": 1720,
    "slowestQuery": 80,
    "queries": [
      { "query": "SELECT * FROM orders", "source": "pg", "queryTime": 30 },
      { "query": "SELECT * FROM products WHERE id = ?", "source": "pg", "queryTime": 35 },
      { "query": "SELECT * FROM products WHERE id = ?", "source": "pg", "queryTime": 28 },
      ...
    ]
  }
}

When you see "calls": 51 on a GET request — that's your N+1.

Cloud Dashboard Detection

Add your API key to see it in the dashboard:

app.use(observe({ apiKey: process.env.APILENS_KEY }));

The Database page in the APILens dashboard automatically flags routes with high DB calls per request as potential N+1 issues.

The N+1 Query Detector table shows:

- Route name

- Average DB calls per request

- Total queries

- Flag for suspicious patterns

No manual counting. No query log parsing. It just works.


How Auto-Detection Works Under the Hood

1. AsyncLocalStorage creates a per-request context that follows the entire async chain

2. Prototype monkey-patching intercepts queries from 8 DB libraries at the driver level

3. Query masking replaces literal values with ? for security

4. Per-request aggregation counts calls, sums time, tracks the slowest query

5. ~50ns overhead per DB call (vs 1-50ms actual query time)

The key insight: by patching at the driver level (not the ORM level), it works with ANY ORM — Prisma, Sequelize, TypeORM, Drizzle, Knex, or raw queries. You don't need to configure anything per-ORM.


Quick Comparison: N+1 Detection Methods

MethodSetupPer-RequestAuto-DetectCross-ORM
Manual counter~30 linesNoNoNo
ORM query logging1 line per ORMNoNoNo
Datadog APM30+ minYesPartialYes
auto-api-observe1 lineYesYesYes

Summary

1. N+1 queries happen when you fetch N related records with N separate queries instead of 1

2. They're invisible during development but kill production performance

3. Every ORM has this problem — Prisma, Sequelize, Mongoose, raw SQL

4. Fix them with eager loading (include), JOINs, or IN clauses

5. Detect them automatically with auto-api-observe — one line of middleware, 8 DB libraries, zero config

Try it

npm install auto-api-observe

- Dashboard: apilens.rest

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

- npm: auto-api-observe

Free during beta. Zero dependencies. 44 tests.