NestJS Provider Scopes and the Traps That Catch You in Production
Your NestJS app leaks request data between users. You added scope: Scope.REQUEST to one provider and now your throughput dropped by 40%. You have no idea which provider is causing it.
All three problems trace back to the same misunderstanding: provider scopes.
Here is the practical checklist. Read it once before you ship, not after.
The 3 Scopes - Plain English
NestJS gives you three lifecycle options for any injectable provider.
- DEFAULT (Singleton) - one instance for the lifetime of the application. Shared by every request.
- REQUEST - a fresh instance per incoming HTTP request. Destroyed after the response.
- TRANSIENT - a fresh instance every time a consumer injects it. Multiple consumers in the same request each get their own copy.
The default is DEFAULT. You opt in to anything else.
![Diagram showing the three NestJS provider scopes - Singleton, Request, and Transient - with instance lifecycles per request][https://res.cloudinary.com/fprznalq/image/upload/v1789182402/twixr-studio/ptrjgldm5zxxzd0ijeqf.jpg]
The Checklist
1. Default to Singleton - Always
If you are unsure, use singleton. It is zero overhead. The instance is created once, wired once, and reused forever.
Most services belong here: repositories, config readers, HTTP clients, queue producers, logger wrappers.
Ask yourself: "Does this service hold any state that is unique to one request?" If the answer is no, it is a singleton.
2. Only Use REQUEST Scope When You Actually Need Per-Request State
The legitimate uses are narrow:
- You need
cls-hookedorAsyncLocalStorage-style request context without external libraries - You are injecting
REQUEST(the raw Express/Fastify request object) into a service and need it available deep in the call tree - You are building a per-request audit trail that writes to a database row tied to that request
Do not use REQUEST scope just to feel safer about shared state. Fix the shared-state bug instead.
3. Understand Scope Bubbling - This Is Where Apps Break
This is the most important rule on this list.
If a singleton injects a REQUEST-scoped provider, NestJS silently promotes the singleton to REQUEST scope.
That promotion bubbles up the entire dependency tree. A single scope: Scope.REQUEST on a low-level utility can turn your top-level service - and every provider that touches it - into a REQUEST-scoped provider.
Result: thousands of instances created and destroyed per second under load. Memory spikes. CPU overhead from garbage collection. Response times climb.
Check the tree before you set any non-singleton scope. Map out who injects what.
4. Never Inject the Raw REQUEST Object Into a Singleton
// WRONG - this silently upgrades your singleton to REQUEST scope
@Injectable()
export class UserService {
constructor(@Inject(REQUEST) private readonly req: Request) {}
}
If you need request metadata inside a singleton (tenant ID, user ID, correlation ID), use AsyncLocalStorage or a purpose-built context module like nestjs-cls. Inject context, not the request.
5. TRANSIENT Scope Is Rarely the Right Answer
TRANSIENT means a new instance per injection point. It is not "a new instance per request" - it is more granular than that. Two consumers in the same module each get separate instances.
The only real use case: a provider that holds genuinely isolated mutable state per consumer (think a builder pattern object). Everything else is overkill.
If you are reaching for TRANSIENT because you think REQUEST is too heavy, the real answer is usually to refactor the state out of the provider entirely.
6. Durable Providers for Long-Lived Non-Singleton State
NestJS 8+ added durable providers - a way to reuse instances across requests that share the same "context" (e.g., the same tenant in a multi-tenant app). The pattern: define a custom context identifier, mark the provider as durable, and NestJS manages a pool of instances keyed by that context.
This is the right tool when you find yourself wanting one instance per tenant rather than one per request. It avoids the full overhead of REQUEST scope while still giving you isolation where it counts.
7. Debugging Scope Issues in Practice
When something looks wrong, these are the places to look:
- Memory grows linearly with traffic - a singleton was silently promoted to REQUEST scope, and something in the chain holds a reference
- Intermittent data bleed between requests - a singleton is mutating shared state; the scope is not the bug, the mutable singleton state is
- Slow startup - TRANSIENT or REQUEST-scoped providers being eagerly instantiated; check for
onModuleInitside effects in non-singleton providers - DI errors about circular dependencies - often surface when scope promotion creates unexpected dependency graphs
The NestFactory.create() call logs the module graph in debug mode. Use it:
const app = await NestFactory.create(AppModule, { logger: ['debug'] });
Pair that with NEST_DEBUG=true to trace every provider instantiation. Noisy, but worth it when you are chasing a scope bug.
![Code card comparing correct AsyncLocalStorage-based context injection versus incorrect raw REQUEST object injection in a NestJS singleton service][https://res.cloudinary.com/fprznalq/image/upload/v1789182413/twixr-studio/ltai7wcbgueqfvx1c2mi.jpg]
8. Testing Gotchas
Unit tests hide scope bugs because the testing module creates providers once and reuses them. The bug only surfaces under concurrent load.
Write an integration test that fires 50 parallel requests and asserts per-request isolation. Jest's built-in concurrency is enough. Do not wait for production to find this.
9. Document Non-Default Scopes in Code
Every scope: Scope.REQUEST or scope: Scope.TRANSIENT should have a comment explaining why. Not for future-you - for the next engineer who reads the provider and wonders why it is not a singleton.
@Injectable({
// REQUEST scope: injects tenant-specific DB connection resolved per request.
// Switching to singleton would share a single tenant connection across all requests.
scope: Scope.REQUEST,
})
export class TenantDatabaseService {}
One comment. Saves 30 minutes of debugging later.
Quick Reference Table
| Scope | Instance Per | Typical Use Case | Overhead |
|---|---|---|---|
| DEFAULT | Application | Most services, repositories, clients | None |
| REQUEST | HTTP Request | Per-request context, audit rows | Medium |
| TRANSIENT | Injection Point | Builder patterns, isolated mutable state | High |
| Durable | Custom Context Key | Multi-tenant instance pools | Low-Medium |
The Pattern That Fixes Most Problems
Stop storing request state in providers. Push it to AsyncLocalStorage (Node 16+, no external dependency). Your providers stay singletons. Your request context travels transparently through the call stack. No scope bubbling. No memory overhead.
The nestjs-cls package wraps this in an idiomatic NestJS interface if you want a ready-made solution.
If you are building AI-driven request pipelines where context needs to flow through multiple services - something I covered in the work behind AI Automation & Chatbots - this pattern is essential. A REQUEST-scoped provider per LLM call is a fast path to a memory problem.
This kind of subtle dependency injection issue is not unique to NestJS. If you work across Laravel too, the N+1 problem is a similar class of "looks fine locally, breaks under load" trap - worth reading Overusing with() and Missing the Real N+1 in Laravel if that stack is also in your toolbox.
![Diagram of NestJS scope bubbling - a REQUEST-scoped provider promoting its singleton consumer up the dependency tree, with an annotation showing where the promotion starts][https://res.cloudinary.com/fprznalq/image/upload/v1789182421/twixr-studio/b8ixmpzjbwn9vfjha2q1.jpg]
The Short Version
- Singleton unless you have a specific reason.
- REQUEST scope bubbles up. Map the tree first.
- Never inject the raw request into a singleton.
- Use
AsyncLocalStoragefor context. Keep providers stateless. - Document every non-default scope with a one-line reason.
Frequently asked questions
DEFAULT (singleton). One instance is created when the application bootstraps and shared across every request for the lifetime of the process. You have to explicitly opt in to REQUEST or TRANSIENT scope.
When a singleton-scoped provider injects a REQUEST-scoped provider, NestJS silently promotes the singleton to REQUEST scope as well. This promotion propagates up the dependency tree. Under load, this means many instances are created and destroyed per second, increasing memory pressure and garbage collection overhead. One misplaced REQUEST scope on a low-level utility can affect dozens of providers.
Use AsyncLocalStorage (available in Node 16+ without any external dependency) or the nestjs-cls package, which wraps AsyncLocalStorage in an idiomatic NestJS interface. Store the context at the start of the request lifecycle (e.g., in a middleware or interceptor) and read it anywhere in the call stack without injecting the raw request object.
Almost never. TRANSIENT gives a fresh instance per injection point, not per request. The only genuine use case is a provider that holds isolated mutable state for a specific consumer - something like a builder or accumulator pattern. If you are considering TRANSIENT for safety reasons, the underlying problem is usually mutable state on a singleton that should be refactored out instead.
Run the app with NEST_DEBUG=true or pass { logger: ['debug'] } to NestFactory.create(). NestJS will log every provider instantiation. Under load, you will see which providers are being created per request instead of once. That is your starting point for tracing the scope bubble back to its source.
Enjoyed this article?
Get notified when I publish new posts on SaaS, Laravel, and remote engineering.



