Data modeling pitfalls: N+1, lazy vs eager, request scope
Three data-modeling traps that look fine in dev and burn p99 in prod: relations that quietly trigger N+1, lazy vs eager defaults that hide or force joins, and request-scoped providers that bubble up the whole graph. Load relations explicitly; reach for durable providers.
The endpoint was fine for a year. GET /authors returned a hundred rows, and someone added one innocent line to the response mapper: posts: author.posts.length. In dev, with eight seeded authors, nobody noticed. In prod, p99 on that route jumped from 40ms to 2.1 seconds and the database CPU graph grew a new plateau. The query log told the story: one SELECT for the authors, then a hundred more — one per author — to lazy-load posts. Nobody wrote a loop with a hundred queries in it. The ORM did, on their behalf, the instant they touched a relation property inside a .map. This lesson is about the three data-modeling defaults that quietly generate that kind of work: relations, lazy-vs-eager, and request scope.
Relations: who owns the foreign key
Before you write the first @OneToMany, ask yourself: which table will own the foreign key column? That decision determines which join is cheaper and which query is the slow one. A relation is just a foreign key with a TypeScript shape on top. @ManyToOne / @OneToMany is the common pair: the many side (Post) owns the foreign key column and carries the @JoinColumn; the one side (Author) only declares the inverse. @ManyToMany introduces a third table — the join table — and one side must be the owning side with @JoinTable(), which is what actually decides the join-table name and columns.
@Entity()
export class Author {
@PrimaryGeneratedColumn() id: number;
// inverse side: no FK column lives here, just the navigation
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
}
@Entity()
export class Post {
@PrimaryGeneratedColumn() id: number;
// owning side: this table holds author_id, this is where @JoinColumn goes
@ManyToOne(() => Author, (author) => author.posts)
@JoinColumn({ name: 'author_id' })
author: Author;
}Knowing the owning side matters because it tells you which table to query to avoid a round trip: to count posts per author you join from Post (which already has author_id), not from Author. Get the owning side wrong and you write the more expensive query without realizing it.
The N+1 problem: one query becomes a hundred and one
Here is the exact shape of the production incident. You load a list of N parents, then access a relation on each one inside a loop. Each access that wasn’t pre-loaded fires its own SELECT. N parents → 1 + N queries.
// N+1: 1 query for authors, then 1 per author for posts = 101 queries for 100 authors
async function listAuthorsBad(repo: Repository<Author>) {
const authors = await repo.find(); // 1 query
return Promise.all(
authors.map(async (a) => ({
name: a.name,
postCount: (await a.posts).length, // +1 query EACH (lazy relation)
})),
);
}The fix is to make the database do the join once. Three options, in rough order of reach:
// Fix A — eager-load this relation FOR THIS QUERY ONLY (one LEFT JOIN, one round trip)
const authors = await repo.find({ relations: { posts: true } });
// Fix B — QueryBuilder when you need columns, conditions, or pagination control
const authors = await repo
.createQueryBuilder('author')
.leftJoinAndSelect('author.posts', 'post')
.getMany();
// Fix C — DataLoader: batch N relation reads into one IN (...) query, dedupe, cache per request
// ideal for GraphQL resolvers where the "loop" is the resolver fan-out you don't control
const loader = new DataLoader((authorIds: readonly number[]) =>
postRepo.find({ where: { author: { id: In([...authorIds]) } } }).then(group(authorIds)),
);Fix A and B collapse 101 queries into 1. DataLoader collapses them into 2 (one for parents, one batched IN (...) for children) and is the right tool when the fan-out happens across independent resolver calls you can’t merge into a single find. The point is that relations: { posts: true } here is a per-query decision — explicit, visible in the call site, and trivially removable when you don’t need the children.
Lazy vs eager: two defaults, both wrong as a default
TypeORM gives you two ways to make a relation load without writing the join, and both are traps when used as the standing default.
Lazy relations type the property as a Promise<T>: accessing it awaits a fresh query. They feel ergonomic — await author.posts “just works” — but that is exactly how the incident happened: every await inside a loop is a hidden query. Lazy relations are N+1 generators; the cost is invisible at the call site because it reads like a property access.
Eager relations (eager: true on the decorator) flip the opposite way: the relation is always joined on every find of that entity, whether or not you need it. You can’t opt out per query with find — so a list endpoint that only needs author names still drags every post across the wire. Eager over-fetches; lazy under-fetches and then back-fills with N queries.
// Lazy: property is a Promise — every access is a query (hidden N+1 generator)
@OneToMany(() => Post, (post) => post.author)
posts: Promise<Post[]>;
// Eager: ALWAYS joined on every find(Author) — you cannot opt out per query
@OneToMany(() => Post, (post) => post.author, { eager: true })
posts: Post[];The senior stance is to avoid both defaults: declare relations plainly (no Promise<> type, no eager: true) and load them explicitly per query with relations or QueryBuilder. That keeps each call site honest about what it fetches and what it costs.
▸Why this works
Why is “always eager” still a trap when it avoids N+1? Because it trades one failure for another: you never get N+1, but you over-fetch on every query of that entity, including the dozens of code paths that never read the relation. A count, an existence check, a name lookup — all now drag a join and its rows. And because eager: true lives on the entity, not the call, you can’t see at the query site that you’re paying for it, and you can’t turn it off with find. Explicit per-query loading is the only option that’s both N+1-free and pay-for-what-you-use.
Request scope: the cost that bubbles up
Providers are singletons by default — one instance for the whole app, shared across every request. That’s almost always what you want. Scope.REQUEST makes Nest instantiate a new provider per incoming request, which you need when the provider must hold per-request context: the authenticated user, a tenant id, a request-scoped transaction’s EntityManager.
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
// a fresh instance per request — safe to stash the current tenant / user here
tenantId: string;
}The senior cost is bubbling: request scope is contagious upward. Any provider that injects a request-scoped provider becomes request-scoped itself, and so does anything that injects that, all the way up the dependency graph. So one request-scoped TenantContext injected deep in a chain can force the whole chain — controller included — to be re-instantiated on every request, paying construction cost and memory per request, and it complicates injecting that chain into things that are inherently singleton, like global guards and interceptors.
The escape hatch is durable providers: mark the provider durable: true and register a ContextIdStrategy that maps requests to a shared sub-tree by some key (commonly the tenant). Now Nest keeps one instance per tenant instead of one per request, so a multi-tenant request-scoped DataSource or context is created once per tenant and reused, cutting the per-request instantiation cost while preserving the per-tenant isolation you actually wanted.
| Scope | Instances | Use when | The catch |
|---|---|---|---|
| DEFAULT (singleton) | One, shared app-wide | Almost always — stateless services, repos | Must not hold per-request state |
| REQUEST | New per HTTP request | Per-request context: tenant, user, txn manager | Bubbles up — re-instantiates the whole chain per request |
| TRANSIENT | New per consumer | Each injector needs its own fresh instance | No sharing at all; easy to over-allocate |
| REQUEST + durable | One per context key (e.g. tenant) | Multi-tenant per-request state you can pool | Needs a ContextIdStrategy; shared across requests of a tenant |
You must return a list of 100 authors each with their posts in a single REST endpoint. How should you load the posts relation?
A repo.find() returns 100 authors. The response mapper does `await author.posts` (a lazy relation) for each. How many queries hit the database?
You inject a Scope.REQUEST TenantContext into a service that a controller depends on. What happens to the scope of the service and controller?
- 01Explain the N+1 problem: how it arises, what it costs for a 100-row list, and the three ways to fix it.
- 02Contrast lazy relations, eager relations, and explicit loading; and explain why request scope is expensive and how durable providers help.
Three data-modeling defaults quietly generate work that looks fine in dev and burns p99 in prod. Relations are foreign keys with a TypeScript shape: the many side owns the FK and @JoinColumn, the one side declares the inverse, and @ManyToMany adds a join table whose owning side carries @JoinTable. The N+1 problem appears when you load N parents and then touch a relation per row in a loop — 1 + N queries, so a 100-row list becomes 101 round trips and a latency cliff; fix it by loading the relation explicitly for that query with find({ relations: { posts: true } }) or QueryBuilder leftJoinAndSelect (one join, one round trip), or by batching with DataLoader (two queries) when the fan-out is a resolver graph you don’t control. Lazy relations (Promise-typed, query-on-await) are hidden N+1 generators; eager: true relations are always joined and over-fetch on every query; the senior stance avoids both and loads relations explicitly per query. Injection scope is the third trap: DEFAULT is a shared singleton, REQUEST is per-request (for tenant/user/transaction context), TRANSIENT is per-consumer — and request scope bubbles up the graph, promoting every consumer to request scope and re-instantiating the whole chain per request, which durable providers (durable: true + ContextIdStrategy) escape by pooling one instance per tenant. Now when you see a p99 cliff appear after someone touched a relation property inside a .map, you’ll know where to look first — and what one-line fix collapses 101 queries back to 1.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.