Skip to content
Knowledge Hub

Provider scopes in NestJS: singleton, request and transient Intermediate

Estimated reading time: 6 min read

CheckoutService needs the current request’s tenant to choose a payment configuration. Both extremes have a cost:

  • The naive way: a middleware assigns this.currentTenant on 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.REQUEST on 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.

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.

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.

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.

  1. Default to DEFAULT; change scope only when there is state that truly lives per request.
  2. Don’t store currentUser or currentTenant in fields of DEFAULT providers.
  3. Before adding Scope.REQUEST, list every provider above it that will be pulled along (especially loggers, datasources).
  4. Metadata (requestId, tenant for logs) goes in ALS; business data is passed as parameters.
  5. Test in parallel with many tenants; a mutable field can pass sequential tests but race under concurrent load.
  6. Count instances created and measure p99 before and after changing scope, using the real graph.
  7. For multi-tenancy with a bounded number of tenants, consider durable providers and measure heap.

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:

Terminal window
npx --prefix examples ts-node examples/src/nestjs-provider-scopes/count-demo.ts
npx --prefix examples ts-node examples/src/nestjs-provider-scopes/scopes-demo.ts