Mesrai
Back to blog
// essayTechnical Deep Dive

The N+1 Query Mesrai Caught in a 14-Line Diff

Real PR catch: loop with `await db.users.findUnique` per order — 1 query becomes 201. ORM include + IN-clause fixes.

Mesrai TeamJuly 31, 20268 min read

An endpoint that lists orders with user info attached. The diff was 14 lines. The bug was the canonical N+1: one query for the orders, one query per order to fetch its user. Mesrai's review fired before the PR's first reviewer arrived.

The vulnerable diff

tsapi/orders.ts
// api/orders.ts
ordersRouter.get("/orders", async (req, res) => {
  const orders = await db.orders.findMany({ where: { ownerId: req.user.id } });
  // BUG: one query per order
  for (const o of orders) {
    o.user = await db.users.findUnique({ where: { id: o.userId } });
  }
  res.json(orders);
});

What is wrong

N+1 is the most common database performance bug in 2026 — the same shape was old when ActiveRecord made it famous. One query loads N rows; the application then loops and runs one query per row to fetch related data. Total: N+1 queries. The fix is to join in the database, fetch related rows in a single `WHERE id IN (...)`, or use a DataLoader that batches per-tick.

The attack

Reproduces immediately under realistic data:

textexploit
# 200 orders, 200 user lookups, 1 outer query = 201 queries
# Postgres EXPLAIN ANALYZE on 200 individual selectByPk lookups:
#   total time: 1,840ms (mostly round-trip latency, 9ms each)
# Replace with a single JOIN:
#   total time: 14ms

Latency scales linearly with row count. The endpoint feels fast in dev with 5 orders, falls over in production with 500.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 90 sec ago

[mesrai] [code-review] [Performance] [N+1] [high]

N+1 query. One select for orders + one per order for users.
Total queries scale with row count: 200 orders → 201 queries.

Fix with an ORM include (joins under the hood):

  const orders = await db.orders.findMany({
    where: { ownerId: req.user.id },
    include: { user: true },
  });

Or with explicit IN-clause batching:

  const orders = await db.orders.findMany({ where: ... });
  const userIds = orders.map(o => o.userId);
  const users = await db.users.findMany({ where: { id: { in: userIds } } });
  // attach back: const userMap = new Map(users.map(u => [u.id, u]));

The fix

tsapi/orders.ts (fixed)
// api/orders.ts — fixed
ordersRouter.get("/orders", async (req, res) => {
  const orders = await db.orders.findMany({
    where: { ownerId: req.user.id },
    include: { user: true },
  });
  res.json(orders);
});

The ORM's `include` produces a single JOIN. All rows in one round-trip. For GraphQL or many-to-many cases where include is not enough, use DataLoader to batch lookups within the same event-loop tick.

Why human review missed it

N+1 looks like a normal loop. Reviewers see the iteration as application logic and miss that each iteration is a database round-trip. The bug also passes tests on small fixtures. Mesrai catches it by flagging every `await db.*.find*` inside a loop.

Related rules + further reading

Mesrai rule pack: performance/no-n-plus-one — flags database calls inside loops.

Prisma docs: eager loading with `include`.

Classic ActiveRecord term, applicable to every ORM since.

Takeaway

Database calls in loops. The single most common performance bug. Mesrai catches every one — include, IN, or DataLoader.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.