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.

Ground this in Ticket’s assignee relation, which TicketsService.list does not currently 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…

10 questions · concept 3 · syntax 2 · practical 3 · logic 2

Concept1. Which statement best defines N+1 & loading strategy?
Syntax2. Which code-level choice matches N+1 & loading strategy?
Practical3. A reviewer spots a bug related to N+1 & loading strategy. What is the right fix?
Logic4. Which reasoning correctly explains N+1 & loading strategy?
Concept5. Which boundary does correct use of N+1 & loading strategy preserve?
Practical6. What is the safest next step when applying N+1 & loading strategy?
Syntax7. Which implementation direction matches the rule for N+1 & loading strategy?
Logic8. Which consequence follows from applying N+1 & loading strategy correctly?
Concept9. Which claim about N+1 & loading strategy is true in this Nest + Postgres monorepo?
Practical10. Which team practice best demonstrates N+1 & loading strategy?