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:
- A query (or any awaited I/O) inside a loop over rows.
for (const x of rows) { await fetchSomething(x.id) }is N+1 by construction. The same goes for.map(async ...)that awaits per item, or a getter on a model that lazy-loads a relation while you iterate. - Query count or latency that scales linearly with list length. If a page is fine with 3 items and crawls with 300, suspect N+1 before anything else. Log your query count per request in development — a sudden "47 queries" on a feed page is the tell.
- The same related row fetched repeatedly. Round-robin or shared relations (authors, categories, the current user's org) get re-fetched once per row when they should be fetched once total.
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.