Monitoring

How to find N+1 queries in a Node.js API before your users do

Count queries per request, flag repeated query shapes, and replace per-item lookups with one batched query.

Find N+1 queries before users do. Cover graphic for a guide to detecting and fixing N+1 database queries in a Node.js API.

The orders endpoint took 40 ms in development and takes 900 ms for your biggest customer. Nothing is wrong with the database; it’s answering 201 queries for one request. That pattern, one query for a list and one more for each item in it, is the N+1 query problem, and it’s the most common reason a Node API gets slower as customers grow.

To find N+1 queries, count database queries per request and flag requests where the same query shape runs many times. You can do that with AsyncLocalStorage and a thin wrapper around your database client in about an hour. To fix them, replace the per-item query with one batched query: a JOIN, a WHERE id = ANY($1), an ORM include, or DataLoader.

What N+1 queries look like in Express

A typical example with node-postgres:

js
app.get('/orders', async (req, res) => {
  const { rows: orders } = await db.query(
    'select id, customer_id, total from orders where account_id = $1 order by id desc limit 200',
    [req.user.accountId],
  );
  for (const order of orders) {
    const { rows } = await db.query('select name, email from customers where id = $1', [order.customer_id]);
    order.customer = rows[0];
  }
  res.json(orders);
});

With 10 orders in your seed data, that’s 11 queries at maybe 0.3 ms each against a local database: invisible. With 200 orders and a managed database 1 to 2 ms away, it’s 201 round trips and several hundred milliseconds, all spent waiting on the network.

Why N+1 queries hide until production

  • Seed data is small. N is 5 locally and 500 for your largest account.
  • Local databases are close. Network round-trip time dominates each query in production, and it’s near zero on localhost.
  • Averages smooth it out. Most accounts are small, so mean latency barely moves. The slowdown shows up in p95 and p99, which is why reading latency percentiles properly matters here.
  • ORMs hide the loop. A lazy-loaded relation or a resolver per field issues queries you never wrote.

How to detect N+1 queries per request

Step 1: track queries in request context

If you already have request context with AsyncLocalStorage, add a query list to the store. Otherwise this is the minimum:

js
// query-tracking.js
import { AsyncLocalStorage } from 'node:async_hooks';

export const queryContext = new AsyncLocalStorage();

export function trackQueries(req, res, next) {
  const store = { queries: new Map(), total: 0 };
  res.on('finish', () => report(req, res, store));
  queryContext.run(store, next);
}

function normalize(sql) {
  return sql.replace(/\s+/g, ' ').replace(/'[^']*'/g, '?').replace(/\b\d+\b/g, '?').trim();
}

export function recordQuery(sql) {
  const store = queryContext.getStore();
  if (!store) return;
  const key = normalize(sql);
  store.queries.set(key, (store.queries.get(key) ?? 0) + 1);
  store.total += 1;
}

Step 2: wrap your database client

Route every query through one function so tracking can’t be skipped:

js
// db.js
import pg from 'pg';
import { recordQuery } from './query-tracking.js';

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

export const db = {
  query(text, params) {
    recordQuery(text);
    return pool.query(text, params);
  },
};

Step 3: log suspicious requests

js
// query-tracking.js (continued)
import { logger } from './logger.js';

const REPEAT_THRESHOLD = 10;
const TOTAL_THRESHOLD = 50;

function report(req, res, store) {
  const repeated = [...store.queries].filter(([, n]) => n >= REPEAT_THRESHOLD);
  if (repeated.length === 0 && store.total < TOTAL_THRESHOLD) return;
  logger.warn({
    route: req.route?.path,
    method: req.method,
    status: res.statusCode,
    totalQueries: store.total,
    repeated: repeated.map(([sql, n]) => ({ sql: sql.slice(0, 200), n })),
  }, 'possible N+1');
}

Register app.use(trackQueries) before your routes. Log the route pattern, not the raw URL, so /orders for every account groups into one line. A day of these warnings gives you a ranked list of endpoints to fix.

Using your ORM’s query log

If you use an ORM, its query log gives you the same signal:

Tool How to see queries
Prisma new PrismaClient({ log: [{ emit: 'event', level: 'query' }] }) then prisma.$on('query', (e) => recordQuery(e.query))
Sequelize logging: (sql) => recordQuery(sql) in the constructor options
Knex knex.on('query', (q) => recordQuery(q.sql))
TypeORM a custom logger implementing logQuery

Hooking those into recordQuery means the same per-request report works regardless of ORM.

Catch N+1 queries in tests

Production detection finds the ones you already shipped. A test stops new ones:

js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { app } from '../app.js';
import { seedOrders } from './seed.js';
import { lastRequestStats } from '../query-tracking.js';

test('GET /orders does not scale queries with order count', async () => {
  await seedOrders({ count: 50 });
  await request(app).get('/orders').set('authorization', 'Bearer test').expect(200);
  assert.ok(lastRequestStats().total <= 3, `ran ${lastRequestStats().total} queries`);
});

lastRequestStats() is a small test-only export that keeps the most recent store. Seed more rows than a developer would by hand; an N+1 is obvious at 50 and invisible at 3.

How to fix N+1 queries

Fetch the related rows in one query, then join in memory:

js
const { rows: orders } = await db.query(
  'select id, customer_id, total from orders where account_id = $1 order by id desc limit 200',
  [req.user.accountId],
);
const ids = [...new Set(orders.map((o) => o.customer_id))];
const { rows: customers } = await db.query(
  'select id, name, email from customers where id = any($1)',
  [ids],
);
const byId = new Map(customers.map((c) => [c.id, c]));
for (const o of orders) o.customer = byId.get(o.customer_id);

Two queries instead of 201, whatever the account size. node-postgres converts the JavaScript array to a Postgres array for any($1). Other options:

  • A JOIN when you always need the related data and the result set stays small.
  • ORM eager loading, such as Prisma include or Sequelize include, instead of lazy access inside a loop.
  • DataLoader for GraphQL resolvers or deeply nested code, where per-item lookups are hard to avoid. It batches all load(id) calls in one tick into a single fetch.

If you only do one thing

On a 1-person team: add the per-request query counter and read the warnings after a day of traffic. Fix the top endpoint. On a 10-person team: also add the query-count test to the endpoints that list things, so the next N+1 fails in CI instead of in production.

FAQ

What is an N+1 query?

One query loads a list of N rows, then N more queries load related data for each row. The total grows with the size of the list, so it gets slower as customers grow.

Does Prisma cause N+1 queries?

include loads relations with a fixed number of queries rather than one per row. N+1 appears when you call findUnique inside a loop or resolve relations per item in GraphQL resolvers.

How many queries per request is too many?

There’s no universal number, but a count that grows with result size is the warning sign. A list endpoint that runs the same query shape 10 or more times per request is worth a look.

Doing this with ApiTraffic

If you’d rather not build query tracking yourself, run your app with the APM preload, node -r @apitraffic/node/register ./server.js, so OpenTelemetry instruments your database driver before it loads. Each query is recorded as a span and attributed to the request that issued it.

Top Operations ranks queries by calls per request and flags N+1 patterns, with the code location that issued them. The request waterfall shows every query on the request’s timeline, so 200 identical rows stand out at a glance, and the endpoint’s Time Breakdown shows how much of its time goes to the database.

Start free and find the N+1 queries in your API: apitraffic.io

Sources: node-postgres docs: queries; Prisma docs: logging; DataLoader README

Keep reading