How NestJS injection scopes work: DEFAULT, REQUEST and TRANSIENT

NestJS assigns every provider an injection scope that controls how long an instance lives and who shares it. You set it with the scope option, for example @Injectable({ scope: Scope.REQUEST }), where Scope is imported from @nestjs/common. Custom providers accept the same scope property.

DEFAULT is the singleton scope. The container builds one instance during bootstrap and shares it across the whole application. Resolution cost is paid once, before the first request arrives.

REQUEST produces a new instance for each incoming request. Teams use it because it gives isolation by construction. Mutable state on the instance, such as a tenant ID, the current user or a per-request cache, cannot leak into a concurrent request. No one has to remember to clear it.

The price is work on the hot path. For each request, the container:

  1. Creates a context identifier for that request.
  2. Walks the subtree of providers that depend on the request-scoped one.
  3. Constructs fresh instances of every provider in that subtree.

Those instances become eligible for garbage collection after the request completes.

To read the request object itself, inject the REQUEST token from @nestjs/core with @Inject(REQUEST). Any provider that does this is effectively request-scoped.

TRANSIENT instances are not shared. Each consumer that injects a transient provider gets its own dedicated instance.

Scope Lifetime When created Sharing Lifecycle hooks
DEFAULT Application lifetime Once, at bootstrap Shared by all consumers Invoked
REQUEST One request On each request, for the affected subtree Shared within a single request Not invoked
TRANSIENT Lifetime of its consumer Each time a consumer is instantiated One instance per consumer Invoked for instances created at bootstrap
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import type { Request } from 'express';

// DEFAULT scope: one instance built at bootstrap, shared app-wide.
@Injectable()
export class ConfigService {
  readonly region = 'eu-west-1';
}

// REQUEST scope: a new instance per incoming request, so mutable
// per-request state cannot leak into a concurrent request.
@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
  readonly tenantId: string;

  constructor(@Inject(REQUEST) private readonly req: Request) {
    // Header name is a placeholder for however your app identifies tenants.
    this.tenantId = String(req.headers['x-tenant-id'] ?? 'unknown');
  }
}

// TRANSIENT scope: each consumer that injects this gets its own instance.
@Injectable({ scope: Scope.TRANSIENT })
export class ScopedLogger {
  private prefix = '';
  setPrefix(prefix: string) { this.prefix = prefix; }
  log(msg: string) { console.log(`[${this.prefix}] ${msg}`); }
}

// Depends on TenantContext, so it becomes request-scoped too and is
// reconstructed on every request along with its request-scoped subtree.
@Injectable()
export class OrdersService {
  constructor(
    private readonly tenant: TenantContext,
    private readonly logger: ScopedLogger,
    private readonly config: ConfigService,
  ) {
    this.logger.setPrefix('OrdersService');
  }
}

Declaring DEFAULT, REQUEST and TRANSIENT providers with the scope option on @Injectable and injecting the REQUEST token, showing how a singleton-declared consumer of a request-scoped provider becomes request-scoped itself.

Scope bubbling: how one request-scoped provider spreads through the graph

Scope is not contained to the provider where you declare it. NestJS resolves dependencies at construction time, so a provider cannot be built once at bootstrap if any of its dependencies only exists per request. A consumer of a request-scoped provider therefore becomes request-scoped itself, and the rule applies again at every level up to the controller.

Take a TenantService that only needs a header. Its constructor uses @Inject(REQUEST) private readonly req: Request and reads req.headers['x-tenant-id']. Injecting REQUEST makes the class implicitly request-scoped, with no scope option required.

Before TenantService reads the request, everything is a singleton:

OrdersController → OrdersService → (TenantService, PricingService, AuditLogger)

After the change, the scope spreads upward through every path that reaches TenantService:

OrdersController* → OrdersService* → (TenantService*, PricingService, AuditLogger) (where * means one instance per request)

OrdersService and OrdersController are now rebuilt for every request. PricingService stays a singleton because it does not depend on TenantService. The spread only moves upward, toward consumers. It does not move downward into dependencies.

The damage grows with how widely the provider is used. A shared logger that adds the tenant ID to each line is injected nearly everywhere, so making it read REQUEST turns most controllers and services into per-request instances. That means many object allocations and constructor calls on each request, all to read a single header.

import { Controller, Get, Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';

// Injecting REQUEST makes TenantService implicitly request-scoped,
// even though no `scope` option is set on @Injectable().
@Injectable()
export class TenantService {
  constructor(@Inject(REQUEST) private readonly req: Request) {}

  getTenantId(): string | undefined {
    // The only reason this class touches the request: one header.
    return this.req.headers['x-tenant-id'] as string | undefined;
  }
}

// Never declared request-scoped, but it depends on TenantService,
// so Nest must build a new OrdersService for every request.
@Injectable()
export class OrdersService {
  constructor(private readonly tenant: TenantService) {}

  list() {
    return { tenant: this.tenant.getTenantId(), orders: [] };
  }
}

// The scope bubbles up one more level: the controller is now
// instantiated per request as well.
@Controller('orders')
export class OrdersController {
  constructor(private readonly orders: OrdersService) {}

  @Get()
  list() {
    return this.orders.list();
  }
}

A TenantService that injects REQUEST only to read the x-tenant-id header becomes request-scoped, which forces OrdersService and OrdersController to be rebuilt on every request.

Where the cost comes from and how to measure it in your own service

Where the cost comes from

The dependency graph is resolved on every request, not once at bootstrap. For each incoming request, Nest builds the whole bubbled subtree again: it resolves dependencies, calls constructors and wires the new instances together. Each instance also allocates its own internal state, such as closures, bound methods, small maps and loggers. All of these objects are short-lived, so the garbage collector has to keep reclaiming them, and under load that work competes with your handlers for CPU.

Lifecycle hooks are also limited. Hooks like onModuleInit run once at startup and are not invoked for request-scoped instances. Any setup that lived there has to move into the constructor, where it runs per request, or into a separate singleton.

Illustrative estimate (assumed numbers, not a benchmark)

  • Formula: constructions per second = request-scoped providers per request × requests per second
  • Assumed: 40 request-scoped providers and 2,000 RPS
  • Result: 40 × 2,000 = 80,000 constructions per second, plus whatever each constructor allocates internally

The real cost depends on Node and framework versions, constructor cost and hardware.

Measuring it in your own service

  1. Count how many providers actually end up request-scoped. Trace the dependency graph from each REQUEST provider upward, or temporarily increment a counter in constructors and check it after a single request.
  2. Load-test the same endpoint twice, once with request scope and once with the provider made singleton. Keep the payload, concurrency and duration identical between runs.
  3. Compare p50, p95 and p99 latency, heap usage over time, and GC frequency and pause time.

Low-traffic services and shallow request-scoped trees may see negligible cost. Measure before refactoring.

Durable providers: reusing request-scoped subtrees per tenant

A durable provider is still request-scoped, but its subtree is keyed by something coarser than the request, usually a tenant ID. NestJS builds that subtree once per key and reuses it for every later request carrying the same key. Requests no longer pay for construction, only for a lookup.

Two pieces make this work. First, a context ID strategy: a class implementing ContextIdStrategy, registered globally with ContextIdFactory.apply() during bootstrap. Its attach method receives the default context ID and the incoming request. It reads the tenant identifier, typically from a header, and fetches or creates a context ID for that tenant from a map. It returns a resolver. For host components whose tree is durable, the resolver hands back the tenant's shared context ID. Everything else gets the per-request one. Second, each provider opts in with durable: true in its @Injectable() options or provider definition. Inside a durable tree, injecting REQUEST gives you the payload your strategy returned, such as { tenantId }, not the live request object.

Durability fits state that is genuinely per tenant: a database connection or pool, a tenant-specific config object, or a client initialized with tenant credentials. It is wrong for data that varies within a tenant, such as the authenticated user, permissions or a correlation ID. Those would leak between requests that share the cached instance.

The trade-offs are real. You now own strategy code that sits on every request path. An incorrect or spoofable key silently mixes data across tenants, so derive it from an authenticated source and validate it. The tenant map and its instances also grow with tenant count. Plan for memory limits, eviction of idle tenants, and cleanup of resources like connections when an entry is dropped.

import { Inject, Injectable, Scope } from '@nestjs/common';
import { ContextId, ContextIdFactory, ContextIdStrategy, HostComponentInfo, REQUEST } from '@nestjs/core';
import { Request } from 'express';

// One shared context ID per tenant. Grows with tenant count; plan eviction.
const tenants = new Map<string, ContextId>();

export class TenantContextIdStrategy implements ContextIdStrategy {
  attach(contextId: ContextId, request: Request) {
    // In production, take the tenant from an authenticated source and validate it.
    // A spoofable key here mixes data across tenants.
    const tenantId = request.headers['x-tenant-id'] as string;

    let tenantSubTreeId = tenants.get(tenantId);
    if (!tenantSubTreeId) {
      tenantSubTreeId = ContextIdFactory.create();
      tenants.set(tenantId, tenantSubTreeId);
    }

    return {
      // Durable trees reuse the tenant's subtree; everything else stays per request.
      resolve: (info: HostComponentInfo) => (info.isTreeDurable ? tenantSubTreeId : contextId),
      // This payload, not the live request, is what durable providers get from REQUEST.
      payload: { tenantId },
    };
  }
}

// main.ts, before app.listen(): ContextIdFactory.apply(new TenantContextIdStrategy());

@Injectable({ scope: Scope.REQUEST, durable: true })
export class TenantConfigService {
  // Built once per tenant, then reused. Never store per-user data here.
  constructor(@Inject(REQUEST) private readonly ctx: { tenantId: string }) {}

  get tenantId() {
    return this.ctx.tenantId;
  }
}

This example shows a context ID strategy that maps each tenant header to one shared context ID, and a request-scoped provider that opts into durability so NestJS builds it once per tenant and gets the strategy's payload from REQUEST.

AsyncLocalStorage as the main alternative for per-request context

If most providers only need a tenant ID, a user ID and a correlation ID, there is a simpler option. You can drop request scope and carry that data in AsyncLocalStorage from node:async_hooks. Services stay singletons, and the context follows the async call chain instead of the DI graph.

Wrap the store in an injectable RequestContext service that exposes typed getters such as tenantId and correlationId, and never hand out the raw store. Open the run() boundary in middleware and call next() inside it. Middleware runs before guards, interceptors and pipes, so the handler and all of those layers execute inside the same context. Any singleton that injects RequestContext can then read the values on demand.

The trade-offs are real:

  • Silent loss. Outside run(), getStore() returns undefined and nothing throws. Context can also go missing in some callback-based or pooled libraries and in event emitters. It never crosses queues or worker threads, so put the IDs in the job payload and open a new run() in the consumer. Getters should throw on missing required values, and tests should cover these paths.
  • Shared-state leaks. Singletons make it tempting to cache request data on instance fields. Under concurrency, one request will read another's data. Keep per-request data in the store only.
  • Overhead. ALS is cheaper than rebuilding a provider subtree, but it is not free. Confirm the difference with load tests on your own service.
  • Hidden dependencies. Ambient context hides what a service needs. Injecting RequestContext keeps the dependency visible and lets tests swap in a simple double.
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { Request, Response, NextFunction } from 'express';

interface Ctx { tenantId: string; correlationId: string; userId?: string }

// Default (singleton) scope: one ALS instance shared by the whole app.
@Injectable()
export class RequestContext {
  private readonly als = new AsyncLocalStorage<Ctx>();
  run<T>(ctx: Ctx, fn: () => T): T { return this.als.run(ctx, fn); }
  // Fail loudly instead of returning undefined outside run().
  private get store(): Ctx {
    const s = this.als.getStore();
    if (!s) throw new Error('RequestContext read outside run()');
    return s;
  }
  get tenantId() { return this.store.tenantId; }
  get correlationId() { return this.store.correlationId; }
  get userId() { return this.store.userId; }
  setUserId(id: string) { this.store.userId = id; } // called by the auth guard
}

@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
  constructor(private readonly ctx: RequestContext) {}
  use(req: Request, _res: Response, next: NextFunction) {
    const tenantId = req.header('x-tenant-id') ?? 'tenant-unknown';
    const correlationId = req.header('x-correlation-id') ?? randomUUID();
    // Guards, interceptors, pipes and the handler all run inside this call.
    this.ctx.run({ tenantId, correlationId }, () => next());
  }
}

@Injectable()
export class InvoiceService {
  constructor(private readonly ctx: RequestContext) {}
  // Read per call; never copy request values onto this.* fields.
  list() { return { tenant: this.ctx.tenantId, cid: this.ctx.correlationId }; }
}

A singleton RequestContext wraps AsyncLocalStorage with throwing getters, middleware opens the run() boundary around next(), and a singleton service reads tenant and correlation IDs on demand.

When to use request scope, durable providers or AsyncLocalStorage

Pick the mechanism by the kind of state you hold, not by habit.

  • Singleton plus AsyncLocalStorage fits ambient, read-mostly context: correlation IDs, trace spans, tenant ID and the authenticated user. Every provider stays a singleton, so the graph is built once at bootstrap and reading context costs about as much as a map lookup.
  • Durable providers fit expensive per-tenant resources such as a tenant's database connection or a client configured from that tenant's credentials. You pay construction once per tenant instead of once per request. Memory grows with the number of tenants, so this suits tens or hundreds of tenants better than an unbounded set.
  • Plain request scope fits small, leaf-level providers that hold mutable per-request state, such as a unit-of-work or a per-request cache. Keep the subtree shallow so the bubbling cost stays small.

Avoid request scope in lifecycle hooks, scheduled jobs, and message or microservice handlers that have no HTTP request. Also avoid it in any provider resolved outside a request context. In these places the REQUEST token is undefined or holds something other than an HTTP request. A manual resolve can also quietly create a fresh instance that is disconnected from the caller's state. These problems show up as runtime errors far from the provider that caused them.

Approach Isolation guarantee Performance profile Complexity Testability Failure modes
Singleton + AsyncLocalStorage Per async context, if the store is entered Graph built once; cheap reads Low to moderate Run the test inside a store, or inject a context service Context is undefined when code runs outside the store or a library breaks async propagation
Durable providers Per tenant (context key) One subtree per tenant; memory grows with the tenant count Moderate (needs a context ID strategy) Requires building context IDs in tests A wrong tenant key shares state across tenants
Request scope Per request Subtree rebuilt on every request Low to adopt, high to contain Resolve through the module reference Scope bubbling; breakage outside HTTP

Refactoring a request-scope-heavy app and the impact on engineering cost

The refactoring path

  1. Inventory the graph. List every provider declared with Scope.REQUEST and every class that injects REQUEST. These are the roots. Everything else that is request-scoped got there through bubbling, so fixing the roots fixes most of the graph.
  2. Replace REQUEST injection with the ALS-backed context service. Most roots inject the request only to read a header, a user ID or a tenant key. The before/after example below shows one such service. The "before" version injects REQUEST to read a correlation header. The "after" version reads the same value from the singleton context service that middleware populates.
  3. Convert or isolate the remaining stateful providers. Make a provider stateless where you can. Where it must hold per-request state, keep it request-scoped but resolve it through the module reference at the call site. This stops it from forcing its consumers into request scope.
  4. Verify and measure. Confirm that controllers are singletons again, for example by asserting instance identity across two requests in an e2e test. Then rerun the load test from section 3 against the same baseline.

Engineering cost

The main risk is context loss. A value read outside an ALS run, or across a callback that breaks async propagation, returns undefined instead of failing loudly. Budget test effort for that case: middleware ordering, guards, interceptors and exception filters.

What you get back:

  • Fewer scope-related runtime errors.
  • Ordinary singleton lifecycle hooks.
  • One context API that also works in queue consumers and event handlers.
  • Less time spent investigating latency.

The refactor isn't worth doing for low-traffic services, for shallow graphs where only one or two providers bubble, or for code scheduled for replacement. In those cases, document the scoped roots and leave them alone.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nestjs request scoped provider, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.