Tags: web-dev concept

N+1 Queries

Date: 2026-08-17


One query to fetch a list, then one more for each item in it. The most common performance bug in application code, and the hardest to see, because every individual query is fast and the code that causes it looks perfectly reasonable.


An N+1 query problem is issuing one query to retrieve N records, then N additional queries to retrieve related data for each — where a single query could have fetched everything.

The shape

const orders = await db.orders.findAll()   // 1
 
for (const o of orders) {
  o.customer = await db.customers
    .findById(o.customerId)                // N
}
100 orders
→ 1 + 100 = 101 queries

each query: 2ms
total:      202ms

one joined query: 3ms

Sixty-seven times slower, and the profiler shows nothing wrong — no single query is slow. The slow thing is the page, and the cause is a count.

Why it hides so well

  • Each query is fast. A slow-query log with a 100ms threshold catches none of them
  • ORMs make it invisible. order.customer looks like a property access and is a database round trip. Lazy loading is the mechanism, and it’s on by default in most ORMs
  • It scales with data, not code. Fine with 10 test records, ruinous with 1,000 real ones
  • It appears in templates. A loop rendering {{ order.customer.name }} triggers it from the view layer, far from any query code

Finding it

1  log every query with a count per request
2  look for the same query shape repeated
   with different parameters
3  a request issuing 50+ queries is
   almost always this

A query counter in development is the single most effective control — log a warning above a threshold and it stops being possible to ship one unnoticed.

The fixes

Eager loading — tell the ORM upfront, and get one query with a join, or two queries total.

orders.findAll({ include: ['customer'] })

Batch fetch — do it yourself, in one query.

const ids = orders.map(o => o.customerId)
const customers = await db.customers
  .whereIn('id', ids)            // ONE query
 
const byId = new Map(
  customers.map(c => [c.id, c]))
 
orders.forEach(o =>
  o.customer = byId.get(o.customerId))

DataLoader — batches and deduplicates requests within a tick, issuing one query. The standard answer in GraphQL.

The batch-fetch pattern is worth internalising — fetch the ids, one query with IN, build a Map, join in memory. It works everywhere, with or without an ORM, and turns O(n) round trips into O(1) — Data Structures.

The trap on the other side

Eager loading everything is its own problem:

include: ['customer', 'items',
          'items.product', 'shipping',
          'payments', 'refunds']

→ one enormous join
→ row multiplication: 100 orders ×
  5 items × 3 payments = 1,500 rows
  to represent 100 orders
→ megabytes over the wire to render
  a list of order numbers

Load what the page uses. Nothing more. The right answer is usually two or three queries, not one giant join and not 101 small ones.

It isn’t only databases

The same shape, worse, over a network:

GET /api/orders            → 100 orders
GET /api/customers/1       ┐
GET /api/customers/2       ├ 100 requests
...                        ┘

100 × 80ms latency = 8 seconds

Latency makes the API version dramatically worse than the database version — Latency and Bandwidth. It’s why REST endpoints grow ?include= parameters and why GraphQL exists, and why an await inside a loop deserves a second look every time — Async Models.

The rule to carry

Any loop containing a query, a fetch, or an await is a potential N+1. That’s the pattern to notice in review. The fix is nearly always the same: hoist the fetch out of the loop and do it once for all the items.