Back to the crumb
The lesson
nodeChewy~25 min

Why the list crawls

What you saw

A 40-item feed that's correct but slow, reporting ~41 database queries to assemble. Shrink the list and the count shrinks with it; grow it and the count grows. The query count is riding the row count — the classic shape of an N+1 query.

What's actually happening

const crumbs = await listCrumbs(limit)        // 1 query: the list

const feed = []
for (const crumb of crumbs) {
  const author = await getAuthor(crumb.authorId)   // 👈 1 query PER crumb
  feed.push({ id: crumb.id, title: crumb.title, author: author.name })
}

One query fetches the list. Then the loop fetches each crumb's author one row at a time — N more queries for N crumbs. That's 1 + N = "N+1." Each query here is cheap (8ms of simulated latency), but they run sequentially, so the costs stack: 40 authors ≈ 40 extra round trips ≈ a third of a second of pure waiting, all of it serialized.

It's made worse by the fact there are only six authors, round-robin across the crumbs — so the loop fetches author #1 again and again and again. The same row, pulled from the database half a dozen times, because each iteration only knows about its own crumb.

This pattern is sneaky because nothing is wrong with any single query, and it's invisible at small scale. With 3 seed rows in development it's 4 queries and feels instant. Ship it, the table grows to thousands, and the endpoint quietly becomes the slowest thing in the app.

The fix

Stop querying inside the loop. Gather the ids you need, fetch them in one query, then stitch the results together in memory:

const crumbs = await listCrumbs(limit)                 // query 1: the list

const ids = [...new Set(crumbs.map((c) => c.authorId))] // unique author ids
const authors = await getAuthorsByIds(ids)              // query 2: all authors at once

const byId = new Map(authors.map((a) => [a.id, a]))     // id -> author, in memory
const feed = crumbs.map((c) => ({
  id: c.id,
  title: c.title,
  author: byId.get(c.authorId).name,
}))

Two queries, flat, no matter whether the feed has 5 rows or 5,000. The Set also collapses the duplicate authors so you fetch author #1 once, not six times. Same response, constant cost.

If you're using an ORM, this is the "eager loading" / "include" feature: crumb.findMany({ include: { author: true } }) (Prisma), Crumb.objects.select_related('author') (Django), .includes(:author) (ActiveRecord). They exist precisely to turn N+1 into a join or a batched IN (...) query for you — but only if you remember to ask.

The bug class

This is the N+1 query — for a parent collection of N rows, the code runs 1 query for the list and N more for each row's related data. It's the single most common database performance bug in web apps, and the most common reason an endpoint that was fast in dev falls over in production.

How to spot it in the wild:

The durable habit: fetch sets, not singletons. When you have a list and need related data, collect the foreign keys, fetch them all in one query (WHERE id IN (...) or an ORM include/join), and join in memory. Any time you find yourself reaching for the database from inside a loop, that's the moment to lift the query out of the loop.