Key Takeaway
Request-scoped providers in NestJS create a fresh instance per request, and that scope bubbles up the whole injection chain — every consumer above a request-scoped provider becomes request-scoped too. The official ceiling is about 5% added latency, but careless use costs more. For request context, prefer AsyncLocalStorage, which keeps providers as singletons.
By default every provider in NestJS is a singleton — instantiated once at bootstrap and shared across the whole application. That is fast and predictable. Then you hit a case that seems to demand per-request state: the current user, a tenant id, a correlation id for logging. The obvious tool is a request-scoped provider, and NestJS makes it a one-line change. That one line is where a lot of teams quietly trade away performance and architectural clarity without noticing.
I have run services where a single request-scoped logger dragged half the module graph into per-request instantiation. Nothing broke — the app just got slower and harder to reason about. This post walks through how scope actually works, the two gotchas that bite in production (bubbling and lifecycle mismatch), and why I reach for AsyncLocalStorage first now.
The three scopes, briefly
- DEFAULT (singleton): one instance for the whole app, created at bootstrap and cached. This is what you want for almost everything — stateless services, repositories, config.
- REQUEST: a new instance is created for every incoming request and garbage-collected when the request finishes. It can inject the original request object.
- TRANSIENT: not shared between consumers — each injector that asks for it gets its own dedicated instance, independent of any request.
Setting the scope is deliberately trivial. You pass it to the Injectable decorator, and inside a request-scoped provider you can inject the raw request using the REQUEST token from @nestjs/core.
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
constructor(@Inject(REQUEST) private readonly req: Request) {}
get userId(): string | undefined {
return (this.req.user as { id?: string })?.id;
}
}Gotcha 1: scope bubbles up the whole chain
This is the one that surprises people. Scope is not local to the provider you decorated. If a controller depends on a request-scoped service, the controller itself becomes request-scoped. If that service is injected three modules deep, every provider along the path that transitively depends on it is now instantiated per request. A single request-scoped leaf can silently convert a large slice of your graph.
The consequence you feel first is a hard rule: a singleton provider cannot inject a request-scoped one. There is no request when the singleton is built at bootstrap, so Nest either promotes the consumer to request scope or refuses to resolve it. That constraint ripples outward and explains a lot of confusing DI errors.
You do not have to discover bubbling at runtime. Pass durable and requiredScope hints, or audit the graph, so an accidental scope change fails loudly at startup instead of degrading throughput in production.
Gotcha 2: the performance and lifecycle cost
The NestJS docs are refreshingly direct here. They state that a properly designed app leveraging request-scoped providers should not slow down by more than about 5% latency-wise — but they also stress it will have an impact, because instances can no longer be cached and must be constructed on every request. Five percent is the well-behaved case, not the worst one.
- Construction cost: constructors, guards, interceptors and pipes tied to the request lifecycle run per request instead of once, multiplying allocation and GC pressure under load.
- Lost caching: any expensive setup you assumed happened once at bootstrap now happens on the hot path of every request.
- Harder testing and reasoning: instance identity is no longer stable, so state you stash on a provider does not behave like the singleton mental model most Nest code assumes.
For genuine multi-tenant cases NestJS offers durable providers: mark a provider durable alongside Scope.REQUEST and Nest reuses the DI sub-tree across requests that share a common attribute like a tenant id, instead of rebuilding it every time. That is the right escape hatch when you truly need per-tenant instances — but it is a scalpel, not a default.
The safer alternative: AsyncLocalStorage
Most of the time you do not actually need a per-request instance. You need per-request data readable from anywhere. That is exactly what Node's AsyncLocalStorage gives you: a store that stays associated with a single async execution context. NestJS explicitly positions it as an alternative to request-scoped providers in its official recipe, and it lets your providers stay singletons.
The pattern is simple: wrap the request early in middleware with als.run(store, next), then read the store from any singleton service later in the same async chain. No bubbling, no per-request construction, no DI constraints.
// als-context.ts
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestStore {
userId?: string;
correlationId: string;
}
export const requestContext = new AsyncLocalStorage<RequestStore>();
// context.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Request, Response, NextFunction } from 'express';
import { requestContext } from './als-context';
@Injectable()
export class ContextMiddleware implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
const store = {
userId: (req.user as { id?: string })?.id,
correlationId: randomUUID(),
};
// run() keeps the store bound to this request's async context
requestContext.run(store, () => next());
}
}
// any singleton service, anywhere downstream
const ctx = requestContext.getStore();
const correlationId = ctx?.correlationId;If you want DI-friendly ergonomics on top of AsyncLocalStorage, the nestjs-cls package wraps it as an injectable ClsService with a ClsModule, guards, interceptors and middleware setup out of the box — singletons all the way down.
AsyncLocalStorage is not free — it carries a small but measurable overhead, and context can be lost if a library breaks the async chain (raw callbacks, some event emitters). Benchmark your setup and keep the store small.
How to decide
| Concern | Request-scoped provider | AsyncLocalStorage |
|---|---|---|
| Provider lifecycle | New instance per request | Stays singleton |
| Scope bubbling | Propagates up the whole chain | None — nothing changes scope |
| Performance | Up to ~5% latency, more if misused | Small measurable overhead |
| Singleton can read it | No — forces consumer to request scope | Yes, from any singleton |
| Best fit | Per-tenant instances, durable providers | Correlation id, current user, logging context |
My rule of thumb: if you only need to read request data (who is calling, a trace id, a locale), use AsyncLocalStorage and keep every provider a singleton. Reach for request scope only when you genuinely need a distinct object graph per request or per tenant — and when you do, add durable providers and scope guards so an accidental bubble fails at startup rather than in your latency graphs.

