Skip to content
← All posts

Nov 10, 2026

Your list page runs one query per row

One list page, one hundred rows, one hundred and one queries. Each one is fast. Together they are the reason the page is slow.

I have written this bug more times than I want to admit, and it never looked like a bug. The code reads fine. The page loads fine — in development, with five rows. Then real data arrives, the list grows to a few hundred rows, and the same page takes seconds. The database is not slow. No single query is slow. The page is slow because it asked the database the same question one hundred times.

It is called the N+1 problem, and it hides in the most innocent-looking code you will ever write:

// 1 query for the list…
const orders = await db.query("SELECT * FROM orders ORDER BY created_at DESC LIMIT 100");

// …then 1 more query for every single row
for (const order of orders) {
  const customer = await db.query("SELECT name FROM customers WHERE id = $1", [order.customer_id]);
  order.customerName = customer.rows[0]?.name;
}

One query for the list, plus one per row. A hundred rows means 101 queries. The loop is the problem, and await inside a for loop makes it look completely normal. That is the whole trap: nothing here errors, nothing here looks wrong, and every individual query returns in a few milliseconds.

Why it hurts is not the query time. It is the round trips. Each query is a separate trip across the network to the database, and a round trip costs roughly the same whether the query returns one row or a thousand. A hundred round trips at a few milliseconds each is a few hundred milliseconds before your code has done anything useful — and under concurrent load, those hundreds of short queries pile up and saturate the connection pool while no single query looks guilty in the logs. This is why the slow query log shows nothing: there is no slow query. There are a hundred fast ones.

It is invisible in development for the same reason. Five rows means six queries, and six fast queries are instant. The bug only becomes visible at production volumes, which is exactly when it is expensive. If you only ever test with a handful of rows, you will ship it every time.

Count the queries, don't guess. The first step is proving it is happening. Turn on query logging in development — in Prisma that is log: ['query'] — load a list page, and count the lines. Six queries for five rows is a warning. A hundred and one for a hundred rows is the diagnosis. In production, the pg_stat_statements extension aggregates normalized queries across all traffic, so a SELECT name FROM customers WHERE id = $1 executed thousands of times stands out even when each execution is fast. Even better, make the count a test: assert how many queries a critical endpoint runs, so a future loop that reintroduces the pattern fails CI instead of reaching production.

Fix it by asking once. The fix is a change in how you think about the fetch: stop asking "what are the orders for this customer" and start asking "what are all the orders for all these customers". There are three ways to do that, and which one fits depends on your stack.

The simplest is eager loading — tell the ORM up front that you will need the related data for every row:

// Prisma: the relation is fetched together with the list,
// in a fixed number of extra queries — not one per row
const orders = await prisma.order.findMany({
  take: 100,
  include: { customer: { select: { name: true } } },
});

The same idea exists everywhere: select_related and prefetch_related in Django, includes in Rails, populate in Mongoose. The name changes; the shape does not.

The second is to let PostgreSQL do the work with a join, and shape the nested data in the query itself:

SELECT o.*, json_agg(c) AS customer
FROM orders o
JOIN customers c ON c.id = o.customer_id
GROUP BY o.id
ORDER BY o.created_at DESC
LIMIT 100;

One round trip, and the database — which is very good at joins — does the combining. If you only need one column, select just that column; batching is not a license to SELECT * everything.

The third is batching, for the cases where a join does not fit — a resolver that runs per row, or data that lives behind another service. Collect the ids first, fetch them all in one query with WHERE id = ANY($1), and stitch the results together in memory:

const customerIds = orders.map((o) => o.customer_id);
const { rows } = await pool.query(
  "SELECT id, name FROM customers WHERE id = ANY($1::bigint[])",
  [customerIds],
);
const byId = new Map(rows.map((r) => [r.id, r]));

GraphQL code has the same problem in a worse form, because every resolver fetches its own data. That is what DataLoader is for: it collects every .load(id) call made in the same tick of the event loop and runs one batched query for all of them. Two rules, both easy to get wrong: create one DataLoader per request, never a global one, or its cache will serve stale or cross-user data; and the batch function must return results in the same order and length as the keys it received, with null for missing keys — a mismatch is the most common DataLoader bug.

A few honest limits, because the fix is not "always eager-load everything". If you only need the related data for a few of the rows, loading it for all of them wastes more than the loop did. If the related set per row is itself large — every user's full order history on a page that shows their five most recent orders — eager loading pulls far more data than the N+1 ever would have, just in fewer trips. And a join changes pagination: apply LIMIT to the parent rows in a subquery, never after a one-to-many join, or the limit will cut joined rows instead of parents. The rule of thumb is to eager-load when you need the related data for most or all of the items, and to profile before assuming the eager version is automatically the win. An N+1 with N = 3 is not worth rewriting.

The slow query was never the problem. The loop was. Count the queries your list page runs, and if the number grows with the number of rows, you have found it.