Skip to main content
N+1 Queries in Node.js APIs: How We Detect Them, Batch with DataLoader, and Enforce a Query Budget — Field Notes

N+1 Queries in Node.js APIs: How We Detect Them, Batch with DataLoader, and Enforce a Query Budget — Field Notes

October 31, 2026
Backend Engineering
8 min read

Key takeaways

• An N+1 query is one query to load a list plus one query per item to load a relation, so database round trips grow linearly with page size even when every individual query is fast.

• We detect N+1 queries by counting queries per request rather than hunting for slow queries: a per-request counter, a staircase of identical spans in an OpenTelemetry trace, or a pg_stat_statements row with an outsized calls count all expose the pattern.

• Most N+1 queries are fixed with a JOIN or one batched WHERE id IN (...) query per relation, followed by an in-memory merge through a Map.

• Promise.all over N queries is not a fix: it still sends N queries and can drain a node-postgres pool, whose default max is 10 connections.

• DataLoader instances must be created per request, never at module scope, and a CI test should assert that the query count stays constant across page sizes.

What is an N+1 query, and why does it pass local testing?

An N+1 query is a data-access pattern in which code runs one query to fetch N parent rows, then one extra query per row to fetch related data, for N+1 queries in total. In the Node.js codebases our team maintains, it almost always comes from an await inside a for loop: load a page of orders, then look up each order's customer one at a time.

Each of those queries is cheap. The cost is the round trip: serialization, network latency to the database, and a pooled connection held for every iteration. On a laptop with a local PostgreSQL instance and a ten-row seed, the endpoint feels instant. In production, with the database across a network and a page size of 50, the same handler makes 51 sequential round trips. Latency scales with page size, and no single statement ever shows up in a slow-query log.

How do we detect N+1 queries before users notice?

We detect N+1 queries by counting queries per request, because the defining symptom is the number of queries, not their duration. Three signals catch it:

• A per-request query counter. An ORM logger hook, such as a Drizzle ORM Logger whose logQuery method runs for every statement, increments a counter stored in AsyncLocalStorage, the Node.js API that scopes a value to one async call chain. The request logs a warning when the count crosses a threshold.

• Trace shape. In an OpenTelemetry trace, an N+1 query appears as a staircase of identical, short database spans under a single HTTP span.

• pg_stat_statements. This PostgreSQL extension records execution statistics per normalized statement. A lookup-by-id statement whose calls count is far higher than the request count of the endpoint that runs it, with a tiny mean_exec_time, is the N+1 signature.

The counter costs one increment per query, so we keep it enabled in production and alert on the warning instead of sampling.

Should we fix an N+1 with a JOIN, a batched query, or DataLoader?

Use a JOIN or eager load when the relation is known at query time, a batched IN query when the service code is under your control, and DataLoader when many independent call sites request the same kind of record. Our comparison:

• JOIN or ORM eager load (Drizzle with, Prisma include): one query, or one per relation level. Best when the relation is known as the query is written. Watch out: one-to-many JOINs repeat the parent columns on every child row.

• Batched IN query plus a Map merge: two queries per page regardless of page size. Best for REST handlers and service code. Watch out: deduplicate ids and handle missing rows and empty lists.

• DataLoader per request: one query per batch. Best for GraphQL resolvers, React Server Components, and scattered call sites. Watch out: the result-order contract, and never share an instance across requests.

• Promise.all over N queries: still N+1 queries, just concurrent. Not a fix, because it can hold up to N pool connections at once.

The batched query is our default for REST handlers. We load the page, collect the distinct customer ids into a Set, run one query with Drizzle's inArray(customers.id, ids), build a Map from id to customer, and attach each customer to its order with byId.get(order.customerId) ?? null. Deduplicating matters because many orders share one customer, and the explicit null keeps an order whose customer was deleted in the response instead of silently dropping it.

How do we use DataLoader without leaking data between users?

DataLoader, the batching and caching utility originally written for GraphQL servers, collects every load(key) call made in the same tick of the event loop and passes all the keys to one batch function. We reach for it when call sites are scattered, such as GraphQL resolvers or nested service calls, where threading an id list through every layer is impractical. Calling load() for every order inside Promise.all is fine here, because DataLoader turns those N calls into one query.

Three rules keep DataLoader safe:

• Return one result per key, in key order. The batch function must return an array with the same length and order as the keys it received. Returning raw database rows, which arrive in arbitrary order, attaches customers to the wrong orders.

• Create loaders per request. DataLoader memoizes every key it loads. A module-level instance serves one user's cached rows to another user and grows without bound, which is both a data leak and a memory leak.

• Cap the batch size. Drizzle's inArray binds one parameter per id, and PostgreSQL limits a single statement to 65,535 bind parameters. The maxBatchSize option splits large batches before they reach that limit.

Do Prisma and Drizzle prevent N+1 queries automatically?

Prisma and Drizzle prevent N+1 queries only when the relation is declared in the query, not when application code loops over results. Drizzle's relational query API, db.query.orders.findMany with a with clause, compiles to a single SQL statement. Prisma's include batches each relation level into its own query instead of one query per row. Prisma Client also batches findUnique() calls issued in the same tick against the same model with the same selection, but it does not batch findFirst() or findMany() calls in a loop. Neither ORM can see across an await inside a for loop.

How do N+1 queries happen in React Server Components?

React Server Components create N+1 queries when every list item is an async component that fetches its own data. Colocated fetching is the point of Server Components, so we batch underneath it rather than hoisting every query to the page. React's cache() memoizes a function for the duration of one server request, which deduplicates identical calls but does not batch calls with different arguments. Wrapping the DataLoader factory in cache() gives each request its own loader, and each component calls getLoaders().customerById.load(id).

Loads issued by sibling components during the same render pass coalesce into one batch. A load issued after an unrelated await lands in a later batch, so we keep load() as the first await in the component.

How do we stop N+1 queries from coming back?

We stop N+1 regressions with a query-budget test: run the same handler at two page sizes and assert the query count is identical. An absolute limit such as "at most five queries" breaks whenever someone adds a legitimate relation, while a count that grows with page size is always a bug.

The test wraps each call in queryStats.run with a fresh counter, calls listOrders with a limit of 2 and then 25 against seeded data, and asserts the two counts are equal using node:assert/strict. It runs with the Node.js built-in test runner against a real PostgreSQL database in CI, so a reintroduced await-in-a-loop fails at review time, which is the only time the fix is cheap.

FAQ

Q: Is Promise.all a valid fix for N+1 queries?

A: No. Promise.all runs the N queries concurrently, which lowers latency but still sends N+1 queries and can occupy every connection in the pool. node-postgres defaults to a pool max of 10, so one large page can starve other requests.

Q: Is DataLoader only for GraphQL?

A: No. DataLoader is a general batching utility for any Node.js code with scattered lookups by key, including REST handlers, service layers, and React Server Components. It started in GraphQL because resolvers are the most common source of scattered lookups.

Q: Should we use a JOIN or a separate batched query for one-to-many relations?

A: For one-to-many relations with wide parent rows, a separate batched query often transfers less data, because a JOIN repeats the parent columns on every child row. For many-to-one relations, a JOIN is usually simpler.

Q: How many queries per request are too many?

A: No fixed number applies across endpoints. The reliable rule is that the query count of a list endpoint should not depend on page size; if doubling the page size changes the count, the endpoint has an N+1 query.