Provider scopes in NestJS: singleton, request and transient Intermediate
Estimated reading time: 6 min read
The problem
Section titled “The problem”CheckoutService needs the current request’s tenant to choose a payment configuration. Both extremes have a cost:
- The naive way: a middleware assigns
this.currentTenanton the service. By default Nest uses one instance for the whole application, so two interleaved requests overwrite each other’s field. - The opposite way: put
Scope.REQUESTon every related service, and Nest has to create a whole new object tree for each request.
Both extremes are wrong. You need to separate per-request data from the lifetime of the whole service.
Instances per scope
Section titled “Instances per scope”The count-demo.ts demo resolves CheckoutService in 100 different contexts (simulating 100 requests, no HTTP). Number of constructor runs, measured on Nest 11:
| Provider | Scope | Instances after 100 requests | Why |
|---|---|---|---|
Config |
DEFAULT |
1 | Shared within the application context |
Formatter |
TRANSIENT |
2 | Each injecting consumer (ReportService, InvoiceService) gets its own copy; those two consumers are default-scoped, so they are created only once |
ReportService, InvoiceService |
DEFAULT |
1 each | Singletons, keeping their Formatter across all requests |
RequestContext |
REQUEST |
100 | One copy per request context |
CheckoutService |
DEFAULT (not declared) |
100 | Injects RequestContext, so it is pulled into request scope |
The last two rows are the commonly missed point: scope “bubbles up” along the injection chain, even when the provider above declares nothing. If CheckoutService is injected into a controller, the controller becomes request-scoped too.
Loading diagram…
Declaring scope:
@Injectable({ scope: Scope.REQUEST })class RequestContext {}
@Injectable({ scope: Scope.TRANSIENT })class Formatter {}For scoped providers in a standalone context, use app.resolve(token, contextId) instead of app.get(). In an HTTP application, Nest creates a context ID for each request automatically.
Choosing REQUEST, ALS or parameters
Section titled “Choosing REQUEST, ALS or parameters”Loading diagram…
| Approach | Instances | Cost | Fits |
|---|---|---|---|
| Parameters | Singleton | Changing function signatures | Business data |
| ALS | Singleton | Implicit dependency on the entry point | Correlation IDs, loggers |
Scope.REQUEST |
One tree per request | Per-request allocation, scope bubbling | State that truly lives for exactly one request (in-request cache, unit of work) |
The Nest docs say request scope in a well-designed application should not raise latency by more than about 5%. That is the docs’ figure, not a promise for your system.
Measure with the real graph, especially when a central provider such as a logger or datasource gets pulled into request scope, because it drags everything above it along.
Request scope also ties code to the transport’s request:
| Transport | Request scope |
|---|---|
| Cron | No request (the Nest docs state Cron controllers must be singletons and cannot use request-scoped providers) |
| Microservices such as Kafka | There is still a per-message context, but getting the request differs from HTTP |
| WebSocket gateway | Scope is tied to the connection, not to each message |
Check the transport before relying on it.
Durable providers for multi-tenancy
Section titled “Durable providers for multi-tenancy”When request scope only needs to differ by tenant (a few dozen tenants, thousands of requests), rebuilding the whole tree for every request is wasteful.
Durable providers let Nest group requests by a key (for example, the tenant) and reuse the dependency tree for the whole group. Per the Nest docs:
@Injectable({ scope: Scope.REQUEST, durable: true })export class TenantConfig {}You also need a ContextIdStrategy that decides which group a request belongs to, registered once at bootstrap:
ContextIdFactory.apply(new AggregateByTenantContextIdStrategy());The strategy reads the tenant from the request (for example the x-tenant-id header) and returns a payload containing the tenant ID, which the durable provider receives via @Inject(REQUEST). Two conditions:
- The number of groups must be bounded (a per-tenant cache with no limit keeps unbounded instances).
- Measure heap with the real number of tenants.
This repo has not run durable providers; the code above follows the Nest docs, so read the “Durable providers” section for the exact version you use.
Real-world example
Section titled “Real-world example”Production checklist
Section titled “Production checklist”- Default to
DEFAULT; change scope only when there is state that truly lives per request. - Don’t store
currentUserorcurrentTenantin fields ofDEFAULTproviders. - Before adding
Scope.REQUEST, list every provider above it that will be pulled along (especially loggers, datasources). - Metadata (requestId, tenant for logs) goes in ALS; business data is passed as parameters.
- Test in parallel with many tenants; a mutable field can pass sequential tests but race under concurrent load.
- Count instances created and measure p99 before and after changing scope, using the real graph.
- For multi-tenancy with a bounded number of tenants, consider durable providers and measure heap.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) Why can currentTenant on a default service be wrong under parallel load? (2) Is a transient Formatter injected into a default consumer recreated on every request? (3) CheckoutService declares no scope but injects a REQUEST provider: how many instances does it have after 100 requests?
Run the demos:
npx --prefix examples ts-node examples/src/nestjs-provider-scopes/count-demo.tsnpx --prefix examples ts-node examples/src/nestjs-provider-scopes/scopes-demo.ts