Ruby has bullet. Python has nplusone.
Node has had to make do with squinting at query logs. This is that tool.
nplusone 2 findings in GET /orders — 10 queries, 14ms
N+1 query 7× SELECT * FROM order_items WHERE order_id = ?
at src/routes/orders.ts:47:38 (loadOrdersPage)
9.3ms spent here
Duplicate query 2× SELECT * FROM settings WHERE user_id = ?
at src/lib/settings.ts:32:29 (loadSettings)
2.4ms spent here
identical parameters — the repeats returned the same rows
| 🔌 | Works with your ORM, whatever it is. It watches the database driver, not the abstraction on top. |
| 📍 | Points at your code. Not "51 queries ran" — this line ran 50 of them. |
| 🧪 | CI gate. expectNoNPlusOne() turns a debugging session into a regression test. |
| 🪶 | Zero runtime dependencies. 29 kB packed. |
| 🔒 | Off in production by default, so nobody pays for stack capture by accident. |
const orders = await db.query("SELECT * FROM orders WHERE user_id = $1", [userId]);
for (const order of orders) {
order.items = await db.query("SELECT * FROM items WHERE order_id = $1", [order.id]);
}Fifty orders, fifty-one queries.
It is invisible in development against a seeded database with three rows, and it is the single most common reason a page that felt instant in review takes four seconds in production.
npm install --save-dev nplusoneTested on Node 18, 20, 22 and 24.
The package is ESM. import works on every version above; require() works
from Node 20.19 onwards, which is where Node backported requiring an ES module.
On Node 18 — end-of-life since April 2025 — use import.
Two lines at boot, one middleware:
import pg from "pg";
import { configure } from "nplusone";
import { instrumentPg } from "nplusone/pg";
import { nplusoneMiddleware } from "nplusone/http";
configure({ threshold: 5 });
instrumentPg(pg);
app.use(nplusoneMiddleware());That is it. Hit a page, and anything suspicious prints to stderr with the line that caused it.
Because the detector hooks the driver, every query builder and ORM on top of that driver is covered without needing its own adapter.
| Your stack | Setup | |
|---|---|---|
PostgreSQL (pg) |
instrumentPg(pg) |
✅ |
PostgreSQL (postgres.js) |
instrumentPostgresJs(sql) |
✅ |
MySQL / MariaDB (mysql2) |
instrumentMysql2(mysql) |
✅ |
SQLite (better-sqlite3) |
instrumentBetterSqlite3(Database) |
✅ |
SQLite (node:sqlite) |
instrumentNodeSqlite(sqlite) |
✅ |
| MongoDB | instrumentMongodb(mongodb) |
✅ |
| Prisma | instrumentPrisma(client) |
✅ |
| Drizzle | driver + instrumentDrizzle(db) |
✅ |
| Knex | via its driver | ✅ |
| TypeORM | via its driver | ✅ |
| MikroORM | via its driver (Knex) | ✅ |
| Sequelize | via its driver | ✅ |
| Kysely | via its driver | ✅ |
| Mongoose | via the MongoDB driver | ✅ |
| Anything else | 10 lines with record() |
🔧 |
Two exceptions worth knowing about.
Prisma does not use
pgormysql2at all — it talks to the database through its own query engine, so driver-level instrumentation cannot see it. Hence a dedicated adapter.Drizzle is detected through its driver, but without attribution. A Drizzle query is a lazy thenable, so the execution is triggered by the runtime calling
.then()— measured against Drizzle 0.45, the stack at that point holds twelve frames and not one of them belongs to your code. AddinginstrumentDrizzle(db)captures the call site while the query is still being built, and the driver adapter uses it. Use both together: the driver reports the SQL, Drizzle reports the line.
Setup for each driver
PostgreSQL — covers Drizzle, Knex, TypeORM, MikroORM, Sequelize, Kysely, raw SQL:
import pg from "pg";
import { instrumentPg } from "nplusone/pg";
instrumentPg(pg);MySQL / MariaDB — also covers mysql2/promise:
import mysql from "mysql2";
import { instrumentMysql2 } from "nplusone/mysql2";
instrumentMysql2(mysql);SQLite:
import Database from "better-sqlite3";
import { instrumentBetterSqlite3 } from "nplusone/sqlite";
instrumentBetterSqlite3(Database);import * as sqlite from "node:sqlite";
import { instrumentNodeSqlite } from "nplusone/sqlite";
instrumentNodeSqlite(sqlite);Drizzle — pair it with the driver adapter. Returns a new db, so use the returned one:
import { drizzle } from "drizzle-orm/postgres-js";
import { instrumentDrizzle } from "nplusone/drizzle";
instrumentPostgresJs(sql); // reports the SQL
export const db = instrumentDrizzle(drizzle(sql, { schema })); // reports the linepostgres.js — returns a new sql, since it is a function rather than an object. Use the returned one:
import postgres from "postgres";
import { instrumentPostgresJs } from "nplusone/postgresjs";
export const sql = instrumentPostgresJs(postgres(process.env.DATABASE_URL));MongoDB — also covers Mongoose, which uses this driver underneath:
import * as mongodb from "mongodb";
import { instrumentMongodb } from "nplusone/mongodb";
instrumentMongodb(mongodb);Prisma — note that this returns a new client, because Prisma clients are immutable. Use the returned one:
import { PrismaClient } from "@prisma/client";
import { instrumentPrisma } from "nplusone/prisma";
export const prisma = instrumentPrisma(new PrismaClient());A scope is the window in which repetition is suspicious — one request, one job, one test. Running the same statement a thousand times a day is normal; running it fifty times while serving one page is not.
Express / Connect / NestJS:
import { nplusoneMiddleware } from "nplusone/http";
app.use(nplusoneMiddleware());Hono, Next.js route handlers, Bun, Deno, Cloudflare Workers:
import { withRequestScope } from "nplusone/http";
export const GET = withRequestScope(async (request) => {
return Response.json(await loadOrders());
});Background jobs, scripts, anything else:
import { runInScope } from "nplusone";
await runInScope("nightly-report", () => generateReport());Just trying it out? autoScope groups queries that arrive with no scope,
closing the group after a short idle gap — so you see something on the first run
without wiring anything:
configure({ autoScope: true });It is a heuristic, and off by default for that reason: concurrent requests can land in the same inferred group and inflate the counts. The report says so whenever a scope was inferred. For numbers you can trust, and for CI, open a real scope.
This is the part that keeps the bug from coming back.
import { expectNoNPlusOne, expectQueryCount } from "nplusone/test";
test("orders page does not N+1", async () => {
await expectNoNPlusOne(() => loadOrdersPage(userId));
});
test("dashboard stays within its query budget", async () => {
await expectQueryCount(() => renderDashboard(userId), 4);
});A failure names the line:
Expected no N+1 queries in orders page, found 1:
N+1 query 50× SELECT * FROM items WHERE order_id = ?
at src/pages/orders.ts:47:38 (loadOrdersPage)
The helpers work whether or not the detector is enabled globally, and restore your configuration afterwards. They throw a plain Error, so Jest, Vitest and node:test all report them correctly with no plugin.
Inside a scope, two different problems get reported — and keeping them apart is the whole trick:
| Finding | What it means | How you fix it |
|---|---|---|
| N+1 query | One statement shape ran from one line with ≥ threshold different values |
Batch it — WHERE id = ANY($1), a join, or a DataLoader |
| Duplicate query | A byte-identical statement with identical parameters ran ≥ duplicateThreshold times |
Cache it, or hoist it out of the loop |
Counting raw repetitions would flag a query that runs ten times with the same argument as an N+1. It isn't one — that's a caching problem with a different fix. So the N+1 rule counts distinct parameter sets, and when a driver interpolates values straight into the SQL, the statement text itself acts as the discriminator.
Statements are normalized before comparison, so WHERE id = 42 and WHERE id = 43 are one shape. The normalizer is a scanner rather than a pile of regexes, because literals need context: -- inside a string is not a comment, the 2 in col2 is not a number, and ::text is a cast rather than a placeholder.
configure({
threshold: 5, // distinct repetitions before it counts as N+1
duplicateThreshold: 2, // identical repetitions before it counts as duplicate
mode: "warn", // "warn" | "throw" | "silent"
statements: ["select"], // restrict to certain statement kinds
ignore: [/pg_catalog/], // skip queries matching these
captureStack: true, // attribute queries to a line of code
autoScope: false, // group unscoped queries heuristically (see above)
autoScopeIdleMs: 50, // idle gap that ends an inferred scope
onFinding: (f) => metrics.increment("n_plus_one", { scope: f.scope }),
reporter: (summary) => logger.warn(summary),
enabled: process.env.NODE_ENV !== "production", // the default
});mode: "throw" raises an NPlusOneError at the query that crosses the threshold — useful in staging when you want the failure to be loud.
record() is public API, so any driver takes a few lines:
import { record } from "nplusone";
const original = driver.execute;
driver.execute = async function (sql, params) {
const started = performance.now();
try {
return await original.call(this, sql, params);
} finally {
record({ sql, params, durationMs: performance.now() - started });
}
};Contributions very welcome — the adapters in src/adapters/ are around 100 lines each and share the helpers in shared.ts.
npm test # 130 tests, including real queries against node:sqlite
npm run coverage # 96% lines, 95% functionsThe real expense is capturing a stack trace per query, which is why the detector is disabled when NODE_ENV === "production" unless you explicitly enable it. Set captureStack: false to keep detection while dropping the "which line" attribution — cheap enough to leave running in staging.
Scopes retain up to 10,000 queries each; past that, counting continues but individual queries stop being kept, so a long-lived scope cannot grow without bound.
Worth knowing before you file an issue:
- Queries with no application frame on the stack — a lazy ORM executing from its own internals, or a pool issuing queries from a background task — are grouped by statement shape alone and reported as
<unknown call site>. For Drizzle this is exactly whatnplusone/drizzlefixes; for an ORM without an adapter yet, the finding still tells you which statement is looping. - A legitimate batch loop (a script importing rows one at a time) looks exactly like an N+1 from the driver's point of view. Use
statements: ["select"]orignoreto quiet it. - Cursors and streaming queries (
pg.Cursor,pg-query-stream) carry no statement text at the patch point and are not recorded. AsyncLocalStorageis required for scope propagation. Code that breaks async context will report queries outside any scope, and you get one warning saying so.- Prisma and MongoDB report operations, not SQL (
User.findUnique,users.findOne) — that's the call you would batch, so it's the more actionable label, but it is not the raw statement. - MongoDB cursors (
find,aggregate) are recorded when the cursor is created rather than when it is drained, so no duration is reported for them.
Not just unit tests. Each release is run against real stacks, and the last one found three bugs that fixtures never would have:
- Next.js bundling gives a route its own copy of the library, so the scope
and the driver ended up on two different
AsyncLocalStorageinstances and nothing was detected. Shared state is now process-global. - Transaction control (
BEGIN/COMMIT) was reported as duplicated work, burying real findings under ORM bookkeeping. - Lazy ORMs execute from a thenable, so the caller's frame is gone by the
time the driver runs. Hence
nplusone/drizzle.
MIT