A blog home page. The 50 newest posts, each with its author's name:

const posts = (await db.query( "SELECT id, title, author_id FROM posts ORDER BY created_at DESC LIMIT 50" )).rows; for (const post of posts) { const { rows } = await db.query("SELECT name FROM authors WHERE id = $1", [post.author_id]); post.author = rows[0].name; } 

On my laptop, with Postgres on the same machine, this takes 2 ms. Looks fine. Ship it.

Then I put a small proxy between the app and the database that holds every reply for 1 ms. A round trip now takes about 1.5 ms, which is roughly what you get when the database is on another machine nearby. Same code, same data: 92 ms.

That's the N+1 query problem. 1 query for the list, then 1 more for every item. 50 posts, 51 queries.

Every query is a trip

Each await db.query(...) sends the SQL to the database and waits for the answer. Finding one author by id takes Postgres a tiny fraction of a millisecond. The waiting is the expensive part.

On your laptop a round trip is about 0.05 ms, so 51 of them cost nothing. In production the database is somewhere else on the network, and the loop makes the trips one after another. You pay for every single one.

Here's what I measured, same 50 posts, median of 21 runs:

Queries Same machine ~1.5 ms round trip
The loop above 51 2.0 ms 91.6 ms
Two queries (fix 2 below) 2 0.2 ms 3.1 ms
One JOIN (fix 1 below) 1 0.1 ms 1.9 ms

With 200 posts on the page, the loop took 416 ms. The JOIN took 2.2 ms.

Drag the round trip from "same machine" to "other zone" and watch what happens to the loop:

Cost simulator with 50 posts. As the round trip grows from 0.1 ms to 5 ms, the N+1 loop goes from a few ms to over 250 ms, while batch and JOIN stay small

You might not even see the loop

The code above at least shows its queries. With an ORM, you often can't see them at all:

An ORM loop where post.author looks like a field. Each step sends another SELECT from authors, ending at 51 queries

post.author looks like reading a field. With lazy loading, it's a database query. Put it in a loop over 50 posts and you get 51 queries without writing a single line of SQL.

The easiest way to catch it is to turn on query logging in development and watch one page load. If you see the same query over and over with a different id, that's it.

Fix 1: JOIN

Ask for the posts and their authors in one query:

const { rows: posts } = await db.query(` SELECT p.id, p.title, a.name AS author FROM posts p JOIN authors a ON a.id = p.author_id ORDER BY p.created_at DESC LIMIT 50`); 

One query, one round trip. 1.9 ms.

Fix 2: two queries, glued together in code

Sometimes a JOIN doesn't fit, for example when the related data comes from somewhere else, or one post has many comments and joining would repeat every post many times. Then fetch the list, collect the ids, and get all the related rows in one go:

const posts = (await db.query( "SELECT id, title, author_id FROM posts ORDER BY created_at DESC LIMIT 50" )).rows; const ids = [...new Set(posts.map((p) => p.author_id))]; const authors = (await db.query("SELECT id, name FROM authors WHERE id = ANY($1)", [ids])).rows; const nameById = new Map(authors.map((a) => [a.id, a.name])); for (const post of posts) post.author = nameById.get(post.author_id); 

Two queries, no matter how many posts. 3.1 ms. This is also what most ORMs do when you ask them to load a relation up front, like include in Prisma or Sequelize.

I checked that all three versions return exactly the same posts and authors.

"Just run them in parallel"

That was the first thing I tried too. Swap the loop for Promise.all:

await Promise.all(posts.map(async (post) => { const { rows } = await pool.query("SELECT name FROM authors WHERE id = $1", [post.author_id]); post.author = rows[0].name; })); 

With a pool of 10 connections, one page went from about 100 ms to 10 ms. Looks like a fix.

It isn't. It's still 51 queries, just sent 10 at a time, and each page now grabs every connection in the pool. Then I loaded the page for 20 users at the same moment:

20 users at once Queries Median page Slowest page
Promise.all 1,020 129 ms 198 ms
JOIN 20 4 ms 5 ms

Every user's 51 queries are fighting for the same 10 connections. On a quiet afternoon it looks fast. On a busy day it's the reason everything is slow.

The thing to remember

A query inside a loop is a network trip inside a loop. Your laptop hides it, because there the network is basically free.

When a list page is slow, count its queries before you look at anything else. If the count grows with the number of items, you've found it.

You can drag N and the round trip yourself and compare all four strategies: https://software-atlas.haiduc0147.dev/t/n-plus-one