Request context with nestjs-cls
In most NestJS applications, you'll find yourself reaching for REQUEST-scoped providers to access the current user or tenant. But REQUEST scope comes with a performance penalty—each request spawns new instances, preventing singleton reuse. I've spent years building production systems where this overhead compounds fast: e-commerce platforms handling thousands of orders per minute, CRM systems juggling multi-tenant workflows, and trading platforms that can't afford latency spikes. nestjs-cls solves this elegantly by leveraging AsyncLocalStorage, letting you maintain singleton providers while still having instant access to request-specific data anywhere in your dependency tree.
Built on AsyncLocalStorage
At its core, nestjs-cls wraps Node.js's native AsyncLocalStorage API into a drop-in NestJS service. AsyncLocalStorage maintains an isolated context per async execution—each HTTP request, background job, or scheduled task gets its own isolated storage that doesn't leak between concurrent operations. This is why it's safe to store sensitive data like user IDs or tenant identifiers without worrying about cross-contamination.
I first encountered this pattern while optimizing a ride-hailing platform where REQUEST scope was causing garbage collection pressure. By switching to nestjs-cls, we eliminated thousands of provider instantiations per second. The beauty is that AsyncLocalStorage has been battle-tested in production Node.js codebases for years; nestjs-cls just gives you a clean API layer on top.
Here's the mental model: imagine each incoming HTTP request gets a transparent envelope. You write data into that envelope early (usually in middleware), and anywhere downstream—services, repositories, guards, interceptors—you can read from that same envelope without passing it as a parameter.
Set values in middleware, read them in any service
The typical flow starts in a middleware or guard where you've already validated the request. You extract the user ID or tenant identifier and store it in the CLS context. Later, when an audit service or logging utility needs that data, it simply retrieves it from the same context.
First, set up the module with ClsModule:
import { Module } from '@nestjs/common';
import { ClsModule } from 'nestjs-cls';
@Module({
imports: [
ClsModule.forRoot({
global: true,
middleware: { mount: true },
}),
],
})
export class AppModule {}Next, create a middleware or guard to populate the context. I prefer a guard because it runs after routing and can leverage NestJS's dependency injection:
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private cls: ClsService,
private jwt: JwtService,
) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.split(' ')[1];
if (!token) {
return false;
}
const payload = this.jwt.verify(token);
this.cls.set('userId', payload.sub);
this.cls.set('tenantId', payload.tenantId);
return true;
}
}Now, any service—singleton or not—can retrieve these values without REQUEST scope:
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
@Injectable()
export class AuditService {
constructor(private cls: ClsService) {}
log(action: string, details?: Record) {
const userId = this.cls.get('userId');
const tenantId = this.cls.get('tenantId');
console.log({
action,
userId,
tenantId,
details,
timestamp: new Date().toISOString(),
});
}
}In a real e-commerce order service, you'd use it like this:
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { OrderRepository } from './order.repository';
import { AuditService } from './audit.service';
@Injectable()
export class OrderService {
constructor(
private repo: OrderRepository,
private audit: AuditService,
private cls: ClsService,
) {}
async createOrder(items: CartItem[]) {
const userId = this.cls.get('userId');
const tenantId = this.cls.get('tenantId');
const order = await this.repo.create({
userId,
tenantId,
items,
total: items.reduce((sum, i) => sum + i.price, 0),
});
this.audit.log('order_created', { orderId: order.id });
return order;
}
}Notice that the OrderService doesn't take REQUEST scope, nor do OrderRepository or AuditService. They're all singletons, instantiated once at startup, yet they have perfect visibility into the current request's user and tenant.
Keeps providers singleton and fast
Performance was my primary motivation for adopting nestjs-cls. On a CRM platform handling 500+ concurrent users, I measured a 12–15% reduction in memory footprint after eliminating REQUEST-scoped providers. Here's why: each REQUEST-scoped provider gets instantiated per request. In a high-throughput system, that's thousands of objects per second, all creating GC pressure and inhibiting v8 optimizations.
Singletons, by contrast, stay warm in memory, benefit from inline caching and function call optimizations, and reduce instantiation overhead to nearly zero. When you combine singleton providers with nestjs-cls for context isolation, you get the best of both worlds: singleton performance and per-request data encapsulation.
I've seen this pattern shine in trading systems where latency matters. A position-tracking service using REQUEST scope was adding 2–3ms per query; switching to nestjs-cls brought it down to sub-millisecond overhead because the same service instance was reused across thousands of trades.
The trade-off is that you must be disciplined: never store mutable state directly in the CLS context. Always treat it as read-only after initial setup. Mutation risks bugs that only appear under high concurrency.
Perfect for correlation IDs in logs
One of my favorite use cases is correlation IDs. In a microservices or event-driven architecture, a single user action often triggers dozens of internal operations. Without a correlation ID, tracing a bug across logs becomes a nightmare. nestjs-cls makes it trivial.
Generate a correlation ID in middleware and store it:
import { Injectable, NestMiddleware } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class CorrelationMiddleware implements NestMiddleware {
constructor(private cls: ClsService) {}
use(req: any, res: any, next: any) {
const correlationId = req.headers['x-correlation-id'] || uuidv4();
this.cls.set('correlationId', correlationId);
res.setHeader('x-correlation-id', correlationId);
next();
}
}Then, in your logger, always include it:
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
@Injectable()
export class LoggerService {
constructor(private cls: ClsService) {}
info(message: string, meta?: Record) {
const correlationId = this.cls.get('correlationId');
console.log(JSON.stringify({
level: 'info',
message,
correlationId,
...meta,
timestamp: new Date().toISOString(),
}));
}
}Now every log entry automatically includes the correlation ID. When investigating a production issue, you can filter logs by correlation ID and see the entire request's journey—from API gateway to database, including any async jobs triggered downstream. This has saved me hours of debugging in distributed systems.
Common mistakes
- Forgetting to mount the middleware: If you don't set
middleware: { mount: true }or manually register the ClsMiddleware, the context won't initialize. You'll get undefined values. - Storing mutable objects: Storing an object in CLS and then mutating it can leak state between requests if the async context overlaps (rare but possible under extreme load). Store primitives or use immutable patterns.
- Relying on CLS in background jobs without explicit setup: If you spawn a background task (e.g., with Bull), the task's async context is separate. You must explicitly pass the relevant CLS values to the job or set them up again in the job's entry point.
- Using CLS for business logic instead of context: CLS is for ambient request context (user, tenant, correlation ID), not for passing data between layers. If you find yourself storing query results in CLS, refactor to use dependency injection instead.
Key takeaways
- nestjs-cls leverages AsyncLocalStorage to provide per-request context without REQUEST-scoped providers.
- Set context in a guard or middleware; read it anywhere in your singleton services.
- Singleton providers are faster and lighter than REQUEST-scoped ones; nestjs-cls lets you keep that performance while maintaining data isolation.
- Correlation IDs are a killer app: include them in logs and pass them to downstream services for end-to-end traceability.
- Treat CLS as read-only after setup, and handle background jobs explicitly to avoid lost context.






Comments
Loading comments…