The N+1 Query Problem: Spot It, Kill It, Prevent It
The single most common performance bug in ORM-backed apps. Here's how it sneaks in and the three reliable ways to stop it.
N+1 is the bug where you run 1 query to load a list of N records, then run N more queries to load a related field — one per record. It's almost never intentional. Every ORM ships with the footgun in the default settings.
The anatomy
// TypeORM / Sequelize / Hibernate — relation is "lazy" by default:
const users = await userRepo.find(); // 1 query
for (const u of users) {
console.log(u.posts.length); // lazy load → 1 query per user
}
// Total: 1 + N queries. A list of 200 users = 201 round trips.
The giveaway in logs: a burst of near-identical queries with only the WHERE-clause ID changing.
Fix #1: eager loading with JOIN
// Prisma
const users = await prisma.user.findMany({ include: { posts: true } });
// TypeORM
const users = await userRepo.find({ relations: ["posts"] });
One query with a JOIN. Good when the relation is 1:1 or small 1:N.
Fix #2: the IN-query (dataloader pattern)
For larger 1:N, a JOIN explodes the row count (cartesian on parent fields). Better: two round trips.
const users = await userRepo.find();
const ids = users.map(u => u.id);
const posts = await postRepo.find({ where: { userId: In(ids) } });
// in-memory group
const byUser = groupBy(posts, p => p.userId);
This is exactly what Facebook's DataLoader does — it batches calls within a request tick and deduplicates IDs.
Fix #3: project what you need
If you only need user.name and COUNT(posts), don't load whole entities. Write the aggregate in SQL. ORMs are not required to mediate every query.
How to prevent it
- Instrument every request. Log query count per request. Any endpoint whose query count scales with input size is a red flag.
- Make lazy loading opt-in. In Hibernate:
fetch = FetchType.LAZYis default — set your DTOs to require explicit fetch planning. - Use a linter. Tools like
eslint-plugin-prismaandn-plus-onedetectors catch the common shapes.
The 2-second endpoint with 847 queries behind it is practically always this one bug.