DEV Community

Cover image for AsyncLocalStorage vs Request-Scoped Providers: The DI Trap That Taxes Latency
Andrii B.
Andrii B.

Posted on AI-assisted

AsyncLocalStorage vs Request-Scoped Providers: The DI Trap That Taxes Latency

Here's a logger. It's a dozen lines and it looks completely harmless:

request-logger.ts

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

@Injectable({ scope: Scope.REQUEST })
export class RequestLogger {
  constructor(@Inject(REQUEST) private readonly req: Request) {}

  log(message: string) {
    console.log(JSON.stringify({ requestId: this.req.headers['x-request-id'], message }));
  }
}
Enter fullscreen mode Exit fullscreen mode

You wanted every log line to carry the request ID. Reasonable. Now inject that logger into OrdersRepository, which is injected into OrdersService, which is injected into OrdersController. Congratulations: none of those are singletons anymore. Every one of them is now built from scratch on every single HTTP request, and none of that is visible in the code of the classes that got converted. The only line that mentions scope at all lives in the logger.

That's the trap. Request scope isn't a property of one provider. It's a property that spreads.

How scope bubbling actually works

NestJS's DI container is built around singletons. At bootstrap, it walks your modules, instantiates each provider once, caches it, and hands the same instance to everyone who asks. That's why the framework docs say plainly that in Nest "almost everything is shared across incoming requests", and that using singletons is safe, because Node isn't running one thread per request.

Scope.REQUEST breaks that model on purpose. A request-scoped provider can't be created at bootstrap, because the request it depends on doesn't exist yet. So Nest has to create it lazily, per request, inside its own DI sub-tree.

Now think about what that means for anything that depends on it. OrdersRepository holds a reference to RequestLogger. If the repository stayed a singleton, it would hold one logger, from whichever request happened to create it first, forever. That's a correctness bug, so Nest doesn't allow it. The docs spell out the rule:

The REQUEST scope bubbles up the injection chain. A controller that depends on a request-scoped provider is itself request-scoped.

Bubbling is transitive. Every provider on the path from the request-scoped leaf up to the controller gets promoted to request scope, implicitly. You don't mark them. You don't see it in their decorators.

And the leaf that triggers it is usually the most widely injected thing in the codebase. Loggers, tenant resolvers, "current user" helpers, audit writers. These sit at the very bottom of the graph and are imported by almost everything. Make one of them request-scoped and you've converted not one branch but most of the tree.

Dependency tree where a single request-scoped RequestLogger turns OrdersRepository, PaymentsRepository, AuditWriter, both services and both controllers implicitly request-scoped, while an unrelated Catalog branch stays singleton

What the tax actually is

The NestJS docs are refreshingly honest about the cost. Request-scoped providers mean instances are recreated per request, and a properly designed application using them "should not see latency increase by more than ~5%."

Read that sentence twice, because both halves matter. Five percent is not a disaster. But "properly designed" is doing a lot of work, and the bubbling behavior is exactly what makes "properly designed" hard to hold onto over time. The overhead scales with the size of the sub-tree Nest rebuilds per request, and the sub-tree grows every time someone injects the request-scoped leaf into one more class. Nobody makes the decision to rebuild 40 providers per request. It happens one innocent constructor parameter at a time.

The CPU for constructing objects isn't even the whole bill. You also pay in:

  • Garbage collection pressure. Every one of those per-request instances has to be collected afterwards. Short-lived objects are cheap in V8, but "cheap" times "every provider in the tree" times "every request" is how you end up staring at GC pauses in a flame graph.
  • Lifecycle hooks you thought you had. The lifecycle docs say it flatly: the hooks "are not triggered for request-scoped classes." A provider that got silently promoted to request scope never runs its onModuleInit warm-up again, and nothing warns you.
  • moduleRef.get() stops working. The docs are explicit: you can't retrieve scoped providers with get(), "including providers that are implicitly request-scoped through their dependencies." You have to switch to await moduleRef.resolve(), which is async and returns a fresh instance per call unless you pass a shared contextId. So a refactor three directories away from your logger can turn a synchronous lookup into a runtime exception.

That last one is the nastiest, because the failure shows up nowhere near the change that caused it.

Where request scope isn't allowed at all

The latency tax is the part everyone talks about. The part that actually forces the architectural decision is that request scope doesn't work everywhere your code runs.

The NestJS docs list places that must stay singletons: WebSocket gateways "should not use request-scoped providers, because they must act as singletons", and the same limitation applies to Passport strategies and Cron controllers. None of those can be rebuilt per request.

Now look at what a real NestJS service does in a day:

  • It serves HTTP.
  • It consumes messages from a broker through a microservice transport.
  • It processes BullMQ jobs.
  • It runs a few @Cron tasks.
  • Maybe it has a WebSocket gateway for live updates.

Each of those has its own story for "per-request" context. HTTP gets REQUEST. Microservice handlers get a different token, CONTEXT, which gives you a RequestContext with the pattern, data, and transport context. BullMQ processors can be declared scope: Scope.REQUEST, in which case "a new instance of the class is created exclusively for each job," and you inject the job through JOB_REF. Cron and gateways get nothing.

So the "request-scoped logger" from the top of the article doesn't just cost latency. It has no idea what to do when it's called from a queue consumer or a cron job, because there's no REQUEST there. You end up with three different injection tokens, a pile of if (this.req) checks, and a logger that silently drops the correlation ID on exactly the background paths where you need it most.

AsyncLocalStorage: context that follows the call, not the constructor

Here's the reframe. What you actually wanted was never "a new logger per request." You wanted "the current request ID, wherever I am in the call stack." That's not a dependency injection problem. It's a context propagation problem, and Node has a primitive built exactly for it.

AsyncLocalStorage (from node:async_hooks) lets you open a store with run(store, callback). Everything executed inside that callback, including every promise continuation and every await that descends from it, can read the same store with getStore(). Two concurrent requests each get their own store even though they interleave on the same event loop.

The NestJS docs have a recipe for this and say outright that ALS "can serve as an alternative to REQUEST-scoped providers, avoiding some of their limitations." The shape is small. Register one AsyncLocalStorage instance as a provider:

als.module.ts

import { Global, Module } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';

export type RequestStore = { requestId: string; userId?: string };

@Global()
@Module({
  providers: [{ provide: AsyncLocalStorage, useValue: new AsyncLocalStorage<RequestStore>() }],
  exports: [AsyncLocalStorage],
})
export class AlsModule {}
Enter fullscreen mode Exit fullscreen mode

Open the store once, at the edge, in middleware:

app.module.ts

import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import { AlsModule, RequestStore } from './als.module';

@Module({ imports: [AlsModule] })
export class AppModule implements NestModule {
  constructor(private readonly als: AsyncLocalStorage<RequestStore>) {}

  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply((req: Request, res: Response, next: NextFunction) => {
        const store: RequestStore = {
          requestId: (req.headers['x-request-id'] as string) ?? randomUUID(),
          userId: req.headers['x-user-id'] as string | undefined,
        };
        this.als.run(store, () => next());
      })
      .forRoutes('*path');
  }
}
Enter fullscreen mode Exit fullscreen mode

And the logger goes back to being a boring singleton:

request-logger.ts

import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
import type { RequestStore } from './als.module';

@Injectable()
export class RequestLogger {
  constructor(private readonly als: AsyncLocalStorage<RequestStore>) {}

  log(message: string) {
    const requestId = this.als.getStore()?.requestId;
    console.log(JSON.stringify({ requestId, message }));
  }
}
Enter fullscreen mode Exit fullscreen mode

No Scope.REQUEST. No bubbling. OrdersRepository, OrdersService, and OrdersController all stay singletons, constructed once at bootstrap. The per-request data moved out of the object graph and into the execution context, which is where it belonged in the first place.

Side-by-side comparison: with Scope.REQUEST each of three requests builds its own Controller, Service, Repository and Logger that is garbage-collected after the response; with AsyncLocalStorage one singleton stack built at bootstrap serves all three requests, each carrying its own requestId store

The same context on every transport

This is where ALS stops being a latency optimization and becomes the obviously right design. The store isn't tied to HTTP. It's tied to "a unit of work started here." So you open it at whatever the edge is for that transport.

For a BullMQ job, the edge is the start of process(). For a message handler, it's the start of the handler. For a cron job, it's the top of the method. Same als.run(), same RequestStore shape, same singleton logger reading it. The code below the edge doesn't know or care which transport started the work, which is exactly the property you want from a logger, an audit writer, or a tenant resolver.

Doing this by hand across four transports gets repetitive, which is why most teams reach for a library. The NestJS docs point to two ready-made options: the official @nestjs/observe SDK, which maintains an ALS store for the requests, jobs, and messages it instruments, and the community package nestjs-cls, with the explicit note that it's third-party and "not managed by the NestJS core team."

nestjs-cls is worth knowing in detail because it exists specifically for this problem. Its own docs call out the places where request scope isn't supported: "passport strategies, cron controllers, websocket gateways, queue consumers." It gives you three ways to open the context (a middleware, preferred for HTTP, plus a guard and an interceptor for other transports), and "the context will be initialized by the first one that the request passes through." For anything that doesn't go through the Nest request pipeline at all, you open it yourself. One detail: with nestjs-cls the store lives inside ClsService, so the logger reads this.cls.get('requestId') instead of the hand-rolled this.als.getStore()?.requestId from earlier.

audio.processor.ts

import { Processor, WorkerHost } from '@nestjs/bullmq';
import type { Job } from 'bullmq';
import { ClsService } from 'nestjs-cls';
import { RequestLogger } from './request-logger';

@Processor('audio')
export class AudioProcessor extends WorkerHost {
  constructor(
    private readonly cls: ClsService,
    private readonly logger: RequestLogger,
  ) {
    super();
  }

  async process(job: Job) {
    return this.cls.run(async () => {
      this.cls.set('requestId', `job-${job.id}`);
      this.logger.log('transcoding started');
      // ... the rest of the job, all with the same context
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The processor stays a singleton. The logger stays a singleton. The correlation ID shows up on background work, which is the one place the REQUEST-scoped version couldn't reach.

The nicest trick in nestjs-cls is proxy providers, borrowed from Spring's request-scoped beans. Instead of rebuilding a DI sub-tree per request, it injects "a SINGLETON Proxy instance, which delegates access and calls to the actual instance, which is created for each request when the CLS context is set up." You get the ergonomics of injecting a per-request CurrentUser class, while the framework only ever sees a singleton, so nothing bubbles. If you genuinely have per-request objects (say a tenant-specific database connection), this is how you keep them without paying for scope promotion.

The gotchas ALS brings with it

ALS isn't free of sharp edges. They're just different, and mostly they're about the store going missing rather than the graph getting heavy.

Context loss on callback APIs. Node's own docs admit that "in rare situations, the current store is lost in one of the asynchronous operations." The usual culprits are callback-based libraries and custom thenables. The official fix is to promisify the callback API with util.promisify(), or tie the operation to the right context with AsyncResource (AsyncLocalStorage.bind() is the shorthand for a single function). The debugging tip is equally practical: log getStore() after each suspect call, and the first one that prints undefined is your leak.

A store object shared across requests. run(store, cb) isolates which store each async chain sees. It does not copy the object. If you build the store once at module scope and pass the same reference into every run(), every request mutates the same object and you've recreated the cross-request leak you were trying to avoid. Build a fresh object per run(), always.

Singleton state is still singleton state. Moving off request scope means every service is shared again. If anyone wrote this.currentUser = ... into an instance field back when the class was request-scoped, that field now leaks between concurrent requests. Grep for instance fields that are assigned per call before you flip a class back to singleton.

The implicit-context smell. The NestJS recipe carries a warning worth taking seriously: ALS "inherently obscures the code flow (by creating implicit context), so use it responsibly, and especially avoid creating contextual 'God objects'." Put a request ID, a user ID, a tenant ID in it. Don't put your entire request, your entity cache, and half your domain state in it. The moment business logic starts branching on values it pulls from ALS instead of receiving them as arguments, your functions stop being testable in isolation. Correlation data and cross-cutting identity belong in the store. Domain inputs belong in parameters.

Note
Since Node.js 24, AsyncLocalStorage is backed by AsyncContextFrame by default instead of the older async-hooks machinery (Stephen Belanger's change, nodejs/node#55552). The release notes describe it as a more efficient implementation of async context tracking. The API didn't change, so existing code gets the new implementation for free once you upgrade.

When request scope is still the right call

I'd push hard for ALS as the default, but request scope isn't wrong everywhere. It earns its keep in a couple of specific shapes.

Multi-tenant with a small number of tenants: use durable providers. If the per-request thing you need is really a per-tenant thing (a tenant's DB connection, a tenant's config), Nest has a feature built for it. Mark the provider @Injectable({ scope: Scope.REQUEST, durable: true }) and implement a ContextIdStrategy that maps requests to a tenant ID. Nest then groups requests by tenant and reuses the sub-tree for each tenant instead of rebuilding it per request. You get isolation without paying the full per-request construction cost.

Genuinely per-request objects with real lifecycle. The microservices docs mention per-request caching in GraphQL as an example edge case. A DataLoader is the classic: it batches and caches for exactly one request and must not outlive it. That's a legitimate per-request object. Even then, a nestjs-cls proxy provider or a factory that keeps the loader in the ALS store gives you the same lifetime without promoting the resolvers that use it.

Small apps where nobody will notice. A service handling a few requests per second with a shallow graph can use Scope.REQUEST and never feel the ~5%. The problem isn't that request scope is slow. It's that it spreads without anyone deciding it should, and the cost of undoing it grows with every class it touches.

My rule of thumb: if what you need is data about the current unit of work, that's context, and it goes in ALS. If what you need is an object whose lifetime must match the unit of work, reach for a proxy provider or a durable provider first, and plain Scope.REQUEST last, on a leaf that nothing important depends on. And whenever you do add Scope.REQUEST, trace upward and count how many providers you just turned into per-request objects. If you don't like the number, that's your answer.


Originally published at andriiboyko.com.

Top comments (2)

Collapse
 
kostyatretyak profile image
Костя Третяк •

and that using singletons is safe, because Node isn't running one thread per request

Yes, this is indeed stated in the NestJS documentation, but it is not entirely accurate. If you assign a value from a request to a class property within a controller or service singleton, that value is shared across all requests currently being processed.

This means that the value of this property may be unpredictably read, overwritten, or deleted by requests from various clients.

Collapse
 
kostyatretyak profile image
Костя Третяк •