DEV Community

Cover image for AsyncLocalStorage vs Scope.REQUEST in NestJS: Fixing a Multi-Tenant Data Leak Without Killing Performance
Divya Shakti
Divya Shakti

Posted on

AsyncLocalStorage vs Scope.REQUEST in NestJS: Fixing a Multi-Tenant Data Leak Without Killing Performance

A while ago in a high-scale multi-tenant B2B SaaS platform, we ran a high-concurrency benchmark before launching an enterprise data portal.

Everything looked solid on paper — modular NestJS architecture, TypeScript type safety, PostgreSQL connection pooling tuned.

Then we saw something that gave every backend engineer in the room chills:

Under concurrent load, Tenant A's dashboard occasionally displayed cached data belonging to Tenant B.

In a multi-tenant B2B SaaS platform handling sensitive enterprise metrics, cross-tenant data leakage is a tier-0 catastrophe.

Here is the exact autopsy of how default NestJS scoping caused the leak, why the classic Scope.REQUEST fix nearly cratered our API performance, and how we solved it cleanly using Node.js AsyncLocalStorage.


1. The Root Cause: The Singleton Trap

By default in NestJS, every provider is a Singleton.

NestJS instantiates your service once during application bootstrap. Every incoming HTTP request shares the exact same class instance.

In our codebase, a developer had created a TenantContextService to hold the currently authenticated tenant:

// DANGEROUS: Singleton service storing request-specific state
@Injectable()
export class TenantContextService {
  private tenantId: string;

  setTenantId(id: string) {
    this.tenantId = id;
  }

  getTenantId(): string {
    return this.tenantId;
  }
}
Enter fullscreen mode Exit fullscreen mode

In a sequential test environment, this works fine. Request 1 sets the ID, queries the DB, finishes.

But Node.js is an event-driven, single-threaded asynchronous runtime.

When Request 1 (Tenant A) awaits a slow database query, the event loop picks up Request 2 (Tenant B). Request 2 overwrites this.tenantId = 'tenant_b'.

When Request 1 resumes execution after its await, this.getTenantId() now returns 'tenant_b'.

Tenant A just queried Tenant B's data.


2. The Naive Fix: Scope.REQUEST (And Why It Almost Killed Us)

The official NestJS documentation mentions that if a service needs request-specific data, you can make it Request-scoped:

// DANGEROUS AT SCALE: Request-scoped provider
@Injectable({ scope: Scope.REQUEST })
export class TenantContextService {
  private tenantId: string;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

While this isolates the state per HTTP request, it triggers a massive cascading effect across your entire dependency injection tree:

  1. Bubble-Up Effect: Any Singleton service that injects TenantContextService automatically becomes Request-scoped.
  2. Object Creation Overhead: If your controller injects 5 services that cascade into Request scope, NestJS must instantiate hundreds of class instances for every single incoming HTTP request.
  3. Garbage Collection Pressure: Under 1,000 requests per second, V8 garbage collection spikes trying to clean up thousands of short-lived transient objects.
  4. Latency Degradation: In our load tests, p99 API latency jumped from 35ms to over 210ms, and memory usage tripled.

Scope.REQUEST traded a data leak for severe latency and CPU starvation. We needed a better way.


3. The Real Solution: Node.js AsyncLocalStorage

Node.js introduced AsyncLocalStorage (ALS) in the async_hooks module to allow storing data throughout the lifetime of a web request across asynchronous continuations — without passing it as function arguments and without instantiating new service classes.

In NestJS, the battle-tested library nestjs-cls wraps AsyncLocalStorage into an idiomatic, blazing-fast module.

Here is the exact architecture we implemented:

Step 1: Register the CLS Module

// app.module.ts
import { Module } from '@nestjs/common';
import { ClsModule } from 'nestjs-cls';

@Module({
  imports: [
    ClsModule.forRoot({
      global: true,
      middleware: {
        mount: true, // Automatically creates ALS context per request
        setup: (cls, req) => {
          // Extract tenant ID from JWT or header cleanly
          const tenantId = req.headers['x-tenant-id'] || req.user?.tenantId;
          cls.set('TENANT_ID', tenantId);
        },
      },
    }),
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Step 2: Access Tenant Context Inside Any Singleton Service

Notice that UserRepository remains a standard, high-performance Singleton:

// user.repository.ts
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';

@Injectable()
export class UserRepository {
  constructor(
    private readonly cls: ClsService,
    private readonly db: DatabaseService,
  ) {}

  async findUsers() {
    // Isolated per request via AsyncLocalStorage
    const currentTenantId = this.cls.get<string>('TENANT_ID');

    return this.db.query(
      `SELECT * FROM users WHERE tenant_id = $1`,
      [currentTenantId],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The Performance Difference:

  • Services remain Singletons: Initialized once at startup.
  • Zero GC spikes: V8 does not allocate or deallocate class trees per request.
  • Strict tenant boundary: No cross-contamination between concurrent asynchronous promises.
  • Latency: Dropped right back down to 34ms (p99) under heavy load.

4. The Failing Test That Protects Us in CI

To ensure this bug could never silently re-enter our codebase, we wrote a concurrent stress test using Promise.all:

it('should maintain strict tenant isolation under concurrent load', async () => {
  const runRequest = async (tenantId: string) => {
    return request(app.getHttpServer())
      .get('/api/v1/users')
      .set('x-tenant-id', tenantId);
  };

  // Fire 100 simultaneous requests interleaved across 2 tenants
  const requests = Array.from({ length: 100 }, (_, i) => 
    runRequest(i % 2 === 0 ? 'tenant_alpha' : 'tenant_beta')
  );

  const responses = await Promise.all(requests);

  responses.forEach((res, index) => {
    const expectedTenant = index % 2 === 0 ? 'tenant_alpha' : 'tenant_beta';
    expect(res.body.tenantId).toBe(expectedTenant);
  });
});
Enter fullscreen mode Exit fullscreen mode

If any service accidentally mutates a shared state, this test fails in CI before code ever touches staging.


Summary Cheat Sheet

Feature Singleton Scope Scope.REQUEST AsyncLocalStorage (CLS)
Instance Creation Once at boot Every single request Once at boot
Memory Footprint Extremely Low High (GC pressure) Extremely Low
Tenant Isolation Leaks state Safe Safe
Performance Impact Blazing fast 3x-5x latency hit Negligible (~1ms)
Production Ready For stateless code Rarely recommended Industry Standard

Enjoyed this breakdown?

I put together a 1-Page Visual NestJS Production Architecture Master Sheet covering multi-tenancy, circular dependency prevention, database indexing rules, and a 10-point pre-deploy checklist:

Grab the 1-Page Master Cheatsheet on Gumroad

(Also available on Contra)

Top comments (0)