NestJS Module 3 – Providers and dependency injection
Injection scopes: singleton, request and transient
When Nest builds providers under each scope, why one request-scoped dependency makes its consumers request-scoped, and request context without it.
What you will learn
- Choose between the default, request and transient scopes for a provider
- Predict how request scope bubbles up a chain of dependencies, and count the instances it builds
- Resolve scoped providers with resolve() instead of get(), and identify the consumer with INQUIRER
- Store request context in AsyncLocalStorage instead of using request scope
Before you start
What you’ll learn · Before you start
What you will learn
- Choose between the default, request and transient scopes for a provider
- Predict how request scope bubbles up a chain of dependencies, and count the instances it builds
- Resolve scoped providers with resolve() instead of get(), and identify the consumer with INQUIRER
- Store request context in AsyncLocalStorage instead of using request scope
Before you start
On this page
One provider marked request scope: Scope.REQUEST: Nest builds one instance of the provider for each request that needs it and drops it after the response. Every class that injects such a provider becomes request-scoped too., RequestContext, holds the kitchen of the current request. After three
requests, Nest had built OrdersService and OrdersController three times each as well, although neither asked for
any scope: the service needs RequestContext and the controller needs the service, so the context’s scope became
theirs.
Scopes decide when Nest builds a provider and who shares it. This lesson compares the three scopes, reproduces the way request scope spreads up a chain of dependencies, and counts what it costs; then it runs transient scope: Scope.TRANSIENT: each class that injects the provider gets an instance of its own, built when that class is built, instead of sharing one instance with the rest of the app. providers and a way to carry request data that keeps every provider a singleton.
Three scopes decide when Nest builds a provider
A provider’s scope is set in its decorator, @Injectable({ scope: Scope.REQUEST }), or with a scope key in a
custom provider. A controller takes one too, in @Controller({ path, scope }), and every route of the controller then
shares it. There are three scopes:
| Scope | Nest builds | Shared by | Built at |
|---|---|---|---|
Scope.DEFAULT |
one instance for the app | every class that asks | start-up |
Scope.REQUEST |
one instance per request | the classes serving that request | each request |
Scope.TRANSIENT |
one instance per consumer | nobody: each consumer has its own | when its consumer is built |
Scope.DEFAULT, the singleton of the previous lessons, is what a provider gets when it asks for nothing. Request
scope gives each request its own object, which may receive the request itself through the REQUEST token of
@nestjs/core; Nest drops it once the response is sent. Transient scope gives each class that injects the provider a
private instance, which suits helpers such as a logger that labels its lines with its owner.
A request-scoped or transient provider is also fetched differently. app.get() returns singletons only, and
resolve() builds scoped instances on demand. The INQUIRER token tells a transient instance which class it was
built for, and Node.js’s AsyncLocalStorage: A Node.js class that keeps a store for all the asynchronous work started inside its run() call, so code deep inside a request can read request data without receiving it as a parameter. can carry request data to singletons without any scope at all.
Checkpoint
Check: The three scopes
One request-scoped dependency makes its consumers request-scoped
Here is the app of the opening. Only RequestContext asks for request scope; it reads the kitchen from the request:
import { Controller, Get, Inject, Injectable, Module, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import type { Request } from 'express';
import { count } from './counts.js';
/** Request-scoped: one per request, built from that request. */
@Injectable({ scope: Scope.REQUEST })
export class RequestContext {
readonly kitchen: string;
constructor(@Inject(REQUEST) req: Request) {
count('RequestContext');
// A real app takes the kitchen from the signed-in user; a header keeps the example short.
this.kitchen = String(req.headers['x-kitchen'] ?? 'unknown');
}
}
/** Declared with the default scope, but it needs RequestContext. */
@Injectable()
export class OrdersService {
readonly n = count('OrdersService');
constructor(private readonly ctx: RequestContext) {}
list() {
return `orders of ${this.ctx.kitchen}`;
}
}
/** The default scope, and nothing request-scoped below it. */
@Injectable()
export class MenuService {
readonly n = count('MenuService');
today() {
return 'poha, dal rice';
}
}
@Controller('orders')
export class OrdersController {
readonly n = count('OrdersController');
constructor(
private readonly orders: OrdersService,
private readonly menu: MenuService,
) {}
/** Answers with the instance numbers of the objects that served this request. */
@Get()
list() {
return { orders: this.orders.list(), controller: this.n, service: this.orders.n, menuService: this.menu.n };
}
}
@Module({ controllers: [OrdersController], providers: [RequestContext, OrdersService, MenuService] })
export class AppModule {} OrdersService needs RequestContext, and a singleton cannot hold an object that exists once per request. So Nest
makes OrdersService request-scoped as well, and then OrdersController, which needs OrdersService. The
documentation calls this scope bubbling: The rule that a class which needs a request-scoped provider, directly or through other classes, becomes request-scoped itself. It spreads up a chain of dependencies, never down. the injection chain. Step through start-up and
three requests:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The declared scopes. Only RequestContext asks for request scope. OrdersService and OrdersController are written with the default scope, and so is MenuService.
- Start-up. MenuService needs nothing request-scoped, so Nest builds it once, while the app starts. The run printed "after start-up: MenuService 1" and nothing else.
- The scope bubbles up. OrdersService needs RequestContext, which exists only per request, so Nest makes OrdersService request-scoped too, and then OrdersController, which needs OrdersService.
- Request 1. A request from pune-07 arrives. Nest builds RequestContext #1 from it, then OrdersService #1 and OrdersController #1, and hands the controller the MenuService built at start-up.
- Request 2. The next request, from pune-12, gets a chain of its own, number 2 of each class. MenuService is still #1.
- Request 3. After three requests the run counted RequestContext 3, OrdersService 3 and OrdersController 3 against MenuService 1. Each chain is released once its response has been sent.
import { session } from '../session.mjs';
// One request-scoped provider at the bottom of a chain: what Nest builds at start-up and for each of three requests.
session(`
npx tsc -p tsconfig.json
node dist/main.js
`); Output
$ npx tsc -p tsconfig.json
$ node dist/main.js
after start-up: MenuService 1
GET /orders (x-kitchen: pune-07) -> {"orders":"orders of pune-07","controller":1,"service":1,"menuService":1}
GET /orders (x-kitchen: pune-12) -> {"orders":"orders of pune-12","controller":2,"service":2,"menuService":1}
GET /orders (x-kitchen: pune-07) -> {"orders":"orders of pune-07","controller":3,"service":3,"menuService":1}
after three requests: MenuService 1, RequestContext 3, OrdersService 3, OrdersController 3
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node run.mjs
The run confirms each step. After start-up only MenuService existed. Every request then received a fresh
RequestContext, OrdersService and OrdersController, numbered 1, 2 and 3 in the answers, while MenuService
stayed #1. Scope bubbles up, never down: MenuService is needed by a request-scoped controller and stays a
singleton, because it needs nothing request-scoped itself.
Key idea
A class becomes request-scoped when anything it needs is, directly or further down, so one request-scoped provider at the bottom of a chain rebuilds the whole chain above it on every request.
That is the cost to watch. This small chain builds 3 objects per request; at 2,000 requests a second that is 6,000 objects a second for the garbage collector, plus the work of every constructor. The documentation’s advice is to prefer singletons and use request scope only where it is truly needed. Nest gives no warning when a scope spreads: the only signs are constructors that run on every request and instance numbers that keep rising, which is why the run counts them.
Checkpoint
Check: Request scope bubbling up
Transient providers, INQUIRER and resolve(), run and counted Pro: opens with a Pro, Premium or Ultimate pass
Transient scope is easy to misread as “a new instance at every injection”, and app.get() cannot fetch it at all.
This part runs both rules on a logger, together with the INQUIRER token.
In this part:
- One instance per consumer, not per parameter
- INQUIRER: the class a transient instance was built for
- get() refuses scoped providers; resolve() builds a new one on each call
- Scoped providers in tests, without surprises
2 runnable examples (JavaScript, TypeScript) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Request context without request scope: AsyncLocalStorage, counted Pro: opens with a Pro, Premium or Ultimate pass
Most request-scoped providers exist to read one or two values of the request, such as its id, its user or its kitchen. AsyncLocalStorage carries those values to singletons instead; this part runs three overlapping requests through it and counts what each approach builds.
In this part:
- A store per request, read by a singleton after every await
- What each approach builds per request, with numbers
- When request scope is still the right tool
2 runnable examples (JavaScript, TypeScript (app.ts), TypeScript (context.ts)) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: count what Nest builds
Write the prediction the bubbling run checked: given an app’s classes and their declared scopes, count the instances after a number of requests. The sample tests run in your browser and start from the lesson’s own app:
Exercise · Medium · JavaScript
Predict the instances request scope builds
Predict what Nest builds. countInstances(graph, requests) receives the classes of an app and the number of requests it has served, and returns how many instances of each class exist by then.
graph maps each class name to { scope, deps }: the scope it is declared with, 'default' or 'request', and the names of the classes its constructor needs. Every request uses every class once. Apply the two rules of this lesson:
- a class is request-scoped when it is declared
'request', or when any class it needs is request-scoped, directly or through others: the scope bubbles up, never down; - a request-scoped class is built once for each request and never at start-up; any other class is built once, at start-up.
Return an object with one entry for every class of graph, in any order: { OrdersService: 3, MenuService: 1, … }. The graphs have no cycles.
Starter code · count_instances.mjs
/**
* The number of instances of each class after start-up and `requests` requests. graph: { [name]: { scope, deps } },
* scope 'default' or 'request'. Returns { [name]: count }.
*/
export function countInstances(graph, requests) {
const counts = {};
for (const name of Object.keys(graph)) {
// Is it request-scoped, declared or because of what it needs?
counts[name] = 1;
}
return counts;
} The sample tests · count_instances.test.mjs
import { assert, test } from 'mysmartcopilot:test';
import { countInstances } from './count_instances.mjs';
/** The lesson's app: RequestContext is request-scoped, the other three are declared with the default scope. */
const lessonApp = {
OrdersController: { scope: 'default', deps: ['OrdersService', 'MenuService'] },
OrdersService: { scope: 'default', deps: ['RequestContext'] },
MenuService: { scope: 'default', deps: [] },
RequestContext: { scope: 'request', deps: [] },
};
test('the lesson app after three requests, as the run counted', () => {
assert.deepEqual(countInstances(lessonApp, 3), { OrdersController: 3, OrdersService: 3, MenuService: 1, RequestContext: 3 });
});
test('after start-up only, request-scoped classes have no instance', () => {
assert.deepEqual(countInstances(lessonApp, 0), { OrdersController: 0, OrdersService: 0, MenuService: 1, RequestContext: 0 });
});
test('the scope bubbles up through several levels', () => {
const chain = { A: { scope: 'default', deps: ['B'] }, B: { scope: 'default', deps: ['C'] }, C: { scope: 'default', deps: ['D'] }, D: { scope: 'request', deps: [] } };
assert.deepEqual(countInstances(chain, 5), { A: 5, B: 5, C: 5, D: 5 });
});
test('the scope never bubbles down to what a request-scoped class needs', () => {
const graph = { Ctx: { scope: 'request', deps: ['Clock'] }, Clock: { scope: 'default', deps: [] } };
assert.deepEqual(countInstances(graph, 4), { Ctx: 4, Clock: 1 });
});
test('a class with one request-scoped branch is request-scoped; the other branch is not', () => {
const graph = {
Top: { scope: 'default', deps: ['Left', 'Right'] },
Left: { scope: 'default', deps: ['Ctx'] },
Right: { scope: 'default', deps: ['Menu'] },
Ctx: { scope: 'request', deps: [] },
Menu: { scope: 'default', deps: [] },
};
assert.deepEqual(countInstances(graph, 2), { Top: 2, Left: 2, Right: 1, Ctx: 2, Menu: 1 });
});
test('a graph without request scope builds one of each, however many requests', () => {
const graph = { Wallet: { scope: 'default', deps: ['Repo'] }, Repo: { scope: 'default', deps: [] } };
assert.deepEqual(countInstances(graph, 1000), { Wallet: 1, Repo: 1 });
});
test('a thousand requests build a thousand of each request-scoped class', () => {
const counts = countInstances(lessonApp, 1000);
assert.equal(counts.OrdersService, 1000);
assert.equal(counts.MenuService, 1);
}); A hint
Write a function isRequestScoped(name) first: it is true when the class is declared 'request', or when any of its deps is request-scoped, which is the same function called on the dependency. The count of a class is then requests when that function says true, and 1 otherwise.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then the questions on the three scopes and on bubbling:
Practice that adapts to you
Practise this lesson
7 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher to mid · Warm-up
What does request scope do in NestJS, and what happens to a singleton that injects a request-scoped provider?
A provider declared with @Injectable({ scope: Scope.REQUEST }) gets one instance per request: Nest builds it when a request needs it, can pass it the request through the REQUEST token, and drops it after the response. It is never built at start-up. A class that injects it cannot stay a singleton, because one shared object cannot hold a different dependency for every request, so Nest makes that class request-scoped too, and every class above it. The scope bubbles up the chain, never down. In this lesson's run, one request-scoped RequestContext made OrdersService and OrdersController request-scoped: after three requests each existed three times, while MenuService, which needs nothing request-scoped, stayed a single instance.
-
Mid to senior
A teammate made a TenantContext provider request-scoped so that services can read the tenant of each request. Afterwards latency rose, garbage collection grew, and an onModuleInit() hook in a repository stopped running. Nothing else changed. Explain all three effects, and how you would keep per-request tenant data without them.
-
Mid to senior
Your logger is a transient provider so that each class can label its own lines. A heap snapshot shows thousands of logger instances in an app with about 40 classes that inject it. What can cause that, and how would you find and fix the cause?
-
Mid to staff
Every log line of a request, in every service, must carry that request's id, including lines written after awaits and in nested calls, and no service should become request-scoped. How would you build this in Nest, and how would you prove it works with many requests in flight?
Key takeaways
- The default scope builds one instance at start-up for everyone, request scope one per request, and transient scope one per consumer.
- A class that needs a request-scoped provider becomes request-scoped itself, and so does everything above it; scope bubbles up, never down.
- A request-scoped chain rebuilds every object in it on each request, so a deep chain under heavy traffic creates many objects for nothing more than one value of the request.
- Scoped providers are fetched with
resolve(), notget(), andINQUIRERtells a transient instance which class it was built for.
Terms in this lesson
- AsyncLocalStorage
- A Node.js class that keeps a store for all the asynchronous work started inside its run() call, so code deep inside a request can read request data without receiving it as a parameter.
- request scope
- Scope.REQUEST: Nest builds one instance of the provider for each request that needs it and drops it after the response. Every class that injects such a provider becomes request-scoped too.
- scope bubbling
- The rule that a class which needs a request-scoped provider, directly or through other classes, becomes request-scoped itself. It spreads up a chain of dependencies, never down.
- transient scope
- Scope.TRANSIENT: each class that injects the provider gets an instance of its own, built when that class is built, instead of sharing one instance with the rest of the app.
References
- NestJS documentation: Injection scopes (NestJS)
- NestJS documentation: Async Local Storage (recipe) (NestJS)
- NestJS documentation: Module reference (resolving scoped providers) (NestJS)
- NestJS documentation: Testing (resolve() and auto mocking) (NestJS)
- NestJS documentation: Lifecycle events (request-scoped classes) (NestJS)
- NestJS documentation: Logger (injecting a custom logger) (NestJS)
- Node.js 24 documentation: Asynchronous context tracking (OpenJS Foundation (Node.js))
Report a problem with this lesson
Kept only in this browser. Your Learn progress