Fullstack CourseLearn by building
Back to week 3

Topic

N+1 & loading strategy

Definition

An N+1 query pattern issues one query to fetch a list of rows and then one additional query per row to fetch each row’s related data, where a single query with an explicit join or batched load would return the same result.

In simpler words

Fetching 20 tickets and then separately fetching each ticket’s assignee, one at a time, means 21 database round trips instead of one.

Center this on a Ticket→assignee relation that a list query does not join — a natural place N+1 could creep in.

After this you can

  • Recognize an N+1 pattern in a code sample
  • Rewrite a per-row lookup as a single joined query
  • Explain why the fix scales better as list size grows

Where this pattern would appear here

Definition

TicketsService.list currently selects tickets without their assignee relation, so any code that later loops over the returned rows to fetch each assignee individually would reintroduce the query for every row instead of loading the relation once.

In simpler words

The risk is not the Ticket–User relation itself; it is loading it one row at a time after the fact.

Ticket has a ManyToOne assignee relation to User with a nullable assignee_id foreign key — well suited to a single joined fetch, risky as a per-row lookup.

A naive implementation might call a repository lookup per ticket inside a .map — 20 tickets means 20 extra queries for one list response.

The N+1 mistake

// Wrong — one query per ticket
const tickets = await qb.getMany();
const withAssignee = await Promise.all(
  tickets.map(async (t) => ({
    ...t,
    assignee: t.assigneeId ? await usersRepo.findOne({ where: { id: t.assigneeId } }) : null,
  })),
);

20 tickets means 20 extra round trips, and the count grows with every additional row on the page.

Loading the relation deliberately

Definition

A deliberate loading strategy fetches related rows in the same query as their parent — through a join such as leftJoinAndSelect, or through TypeORM’s relations option — so the total query count stays fixed regardless of how many parent rows are returned.

In simpler words

One query, or one join, for the whole page — not one per row.

qb.leftJoinAndSelect("ticket.assignee", "assignee") adds the relation to the same SQL statement TicketsService.list already builds, with no extra round trip.

Select only the columns the response actually needs when a joined relation gets wide — toResponse() already narrows the ticket shape; do the same for a hydrated assignee.

Right — one query for the page

const qb = this.ticketsRepo
  .createQueryBuilder('ticket')
  .leftJoinAndSelect('ticket.assignee', 'assignee');
// ...same status/q filters, skip/take...
const [rows, total] = await qb.getManyAndCount();

Assignee data now arrives with the tickets in a single round trip, however many rows the page contains.

How to prove N+1 in practice

Definition

You diagnose N+1 by counting SQL statements for a single list request, not by reading TypeScript and hoping. Enable query logging or use a DB/proxy that shows statement counts.

In simpler words

If one GET /tickets produces 1 + pageSize queries, you found it.

Turn on TypeORM logging (or Postgres log_min_duration) in local dev while hitting the list endpoint once.

Compare: without join → many SELECTs on users; with leftJoinAndSelect → one (or two with count) statements.

DataLoader-style batching (conceptual): collect ids during a request, then fetch once — useful in GraphQL; in REST Nest services, prefer an explicit join or In(ids) batch.

Measurement checklist

1. Hit GET /tickets?limit=20 once
2. Count SELECT statements in logs
3. If count ≈ 1 + 20 on users → N+1
4. Add leftJoinAndSelect / relations / In(ids)
5. Re-hit; count should stay ~constant as limit grows

Intermediate habit: measure before and after, do not argue from intuition alone.

Keep in mind

  • Spot N+1 by counting queries in dev logs against one list response, not by guessing.
  • Prefer a join or a single relations fetch over a per-row lookup.
  • Re-check loading strategy whenever a list endpoint gains a new relation.
  • Prove the fix by re-counting queries after the change.

Test

Check your understanding

At least 10 questions — mix of concept, syntax, practical, and logic. Score ≥80% (enforced by the API) to save progress.

Checking your session…

15 questions · concept 5 · syntax 3 · practical 4 · logic 3

1. What is the N+1 problem?
Concept
2. How can TicketsService.list avoid N+1 for assignees?
Syntax
3. Why is N+1 painful?
Practical
4. Does N+1 only affect writes?
Logic
5. Is running exactly two queries always N+1?
Concept
6. What does leftJoinAndSelect do?
Practical
7. When might DataLoader/batching help?
Syntax
8. How do you notice N+1 in logs?
Logic
9. Does pagination make N+1 harmless?
Concept
10. Which fix is wrong?
Practical
11. What performance problem is this?
Conceptadvanced
const tickets = await repo.find();
for (const t of tickets) {
  t.assignee = await userRepo.findOneBy({ id: t.assigneeId });
}
12. How many queries does this run to load tickets with assignees?
Syntaxintermediate
repo.createQueryBuilder('t')
  .leftJoinAndSelect('t.assignee', 'u')
  .getMany();
13. What does the relations option do here?
Practicalintermediate
repo.find({ relations: { assignee: true } });
14. Is this the N+1 problem?
Logicadvanced
const tickets = await repo.find();
const users = await userRepo.find();
15. What pattern do these query logs show?
Conceptadvanced
SELECT * FROM tickets;
SELECT * FROM users WHERE id = $1;
SELECT * FROM users WHERE id = $1;
SELECT * FROM users WHERE id = $1;

Checking your session…