Request-Scoped Providers in NestJS: DI Gotchas

Photo by Original photo: User:Fanghong Derivative work: User:Gnomz007 via Wikimedia Commons (CC BY-SA 3.0)
A request-scoped provider gets a brand-new instance created for every incoming HTTP request, and that instance is garbage-collected when the request finishes. This is different from the default singleton scope, where one shared instance lives for the entire application lifetime. You opt in with Scope.REQUEST on the Injectable decorator.
Because instances can no longer be cached and constructed once at bootstrap — Nest must build them, along with their guards, interceptors and pipes, on every single request. The official docs say a well-designed app should stay under about 5% added latency, but misuse multiplies allocation and garbage-collection pressure under load. The cost grows with how much of your graph the scope infects.
Scope bubbling is when a request-scoped provider forces everything that depends on it to also become request-scoped. If a controller injects a request-scoped service, the controller becomes request-scoped, and so does anything above it in the chain. A single request-scoped leaf can silently convert a large part of your module graph.
No. A singleton is instantiated once at bootstrap when no request exists, so it cannot hold a reference to something that only exists per request. Nest will either promote the consumer to request scope or fail to resolve the dependency. This constraint is the root of many confusing DI errors and is a strong reason to prefer AsyncLocalStorage for shared context.
Use AsyncLocalStorage whenever you only need to read per-request data — the current user, a tenant id, a correlation id for logging — from anywhere in the code. It keeps all your providers as singletons and avoids scope bubbling entirely. Reserve request-scoped providers for cases that genuinely need a distinct object graph per request or per tenant, such as durable multi-tenant providers.

Photo by Original photo: User:Fanghong Derivative work: User:Gnomz007 via Wikimedia Commons (CC BY-SA 3.0)
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.
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;
}
}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.
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.
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.
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.
| 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.