Lesson 02 of 04

The N+1 problem

Beginner9 min

The most common performance bug in application code, why ORMs make it invisible, and the three ways out.

OutcomesAfter this you will be able to

  • Spot an N+1 in code and in a query log
  • Choose between a join, a batched IN, and a dataloader
  • Explain why the fix is not always "add a join"

01What it looks like

One query to fetch a list, then one more query per item in that list. Fifty posts becomes fifty-one round trips. Each is fast on its own — two milliseconds, nothing in a log — but the latency is per-query and it adds up in a straight line.

ts
// 1 query
const posts = await db.select().from(postsTable).limit(50);

// + 50 queries, one per iteration
for (const post of posts) {
  post.author = await db.query.users.findFirst({
    where: eq(users.id, post.authorId),
  });
}

02Three fixes, in order of preference

A join is the right default. One query, one round trip, the database does the work it is built for.

ts
// 1 — join. best when the relation is one-to-one or many-to-one.
const rows = await db
  .select({ post: postsTable, author: users })
  .from(postsTable)
  .leftJoin(users, eq(users.id, postsTable.authorId))
  .limit(50);

Two queries with an `IN` beat a join when the relation is one-to-many and a join would multiply your rows. Fifty posts with twenty comments each is a thousand rows from a join, with every post's columns repeated twenty times. Fetching separately and stitching in memory moves less data.

ts
// 2 — batch. two queries total, no row multiplication.
const posts = await db.select().from(postsTable).limit(50);
const ids = posts.map((p) => p.id);

const comments = await db
  .select()
  .from(commentsTable)
  .where(inArray(commentsTable.postId, ids));

const byPost = Map.groupBy(comments, (c) => c.postId);
for (const post of posts) post.comments = byPost.get(post.id) ?? [];

A dataloader is for when you genuinely cannot restructure the call sites — a GraphQL resolver tree, or deeply nested components each fetching their own data. It collects the ids requested within a tick and issues one batched query. Powerful, and a lot of machinery for a problem a join usually solves.

03Make it visible

You will not catch N+1 by reading code, because the loop and the query are often in different files. Catch it by counting.

  • Log the query count per request in development and print it in the response headers.
  • Set `log_min_duration_statement = 0` on a local Postgres and watch the log while you click around.
  • Add a test that asserts a known endpoint issues no more than N queries. It fails the day someone adds a lazy relation.

Exercise

Count your queries

Add a per-request query counter to an app you have built and load your busiest page. Most people find at least one endpoint issuing more than thirty queries where three would do. Fix the worst one with a join and measure the difference in p95 latency, not average.