Bordered avatar

Street Learner

Author
7 min read

Last Updated: a year ago

NestJS Provider Scopes: The Hidden Superpower Seniors Use

NestJS Provider Scopes: The Hidden Superpower Seniors Use

When building NestJS apps, we inject services into controllers, modules, guards, or interceptors without worrying about how they are created. But senior developers know one thing clearly:

Not all services should live the same life.

Some services must be shared, some must be isolated per request, and some must be fresh every time they are used. NestJS lets us control this using Provider Scopes.

Understanding scopes prevents memory leaks, stale data issues, and shared state bugs.

The Three Scopes

Singleton Scope

  • This is the default behavior in NestJS.
  • NestJS makes one instance and shares it across the whole app.
  • Perfect for services that don’t store user data or request data.

Think about:

  • App-wide logger
  • Configuration manager
  • Shared utilities

Request Scope

  • Creates a new instance for every HTTP request.
  • Best when you need to save request-based data like current user or request-level logs.
  • It lives only until the request finishes and is then destroyed.

Transient Scope

  • Creates a new instance every time it is injected.
  • Even within the same request, if you inject it in two places, you get two different instances.
  • Useful for isolated operations like creating unique payment transactions or one-time processors.

Let’s Build a Real-World Inspired Example

We’ll create an Audit Tracking System — a feature used in big applications to log who did what, but without letting user data leak between requests or operations.

1. Global Audit Logger (Singleton)

Create a new module:

nest g module audit
nest g service audit/global-audit

global-audit.service.ts

import { Injectable } from '@nestjs/common';

@Injectable()
export class GlobalAuditService {
  logEvent(event: string) {
    console.log(`[AUDIT-EVENT]: ${event}`);
  }
}

This runs globally and remains the same instance everywhere.

2. Request Tracker (Request-Scoped Provider)

Generate service:

nest g service audit/request-tracker

request-tracker.service.ts

import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST })
export class RequestTrackerService {
  private actionBy: string;
  private reqId: string = `REQ-${Date.now()}-${Math.floor(Math.random() * 1000)}`;

  setActionBy(user: string) {
    this.actionBy = user;
    console.log(`Action recorded for request ${this.reqId}`);
  }

  getRequestId() {
    return this.reqId;
  }

  getActionBy() {
    return this.actionBy;
  }
}

Every request gets a new RequestTrackerService, with its own unique request ID.

3. One-Time Processor (Transient Scoped Provider)

Generate another service:

nest g service audit/job-processor

job-processor.service.ts

import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.TRANSIENT })
export class JobProcessorService {
  private jobId = `JOB-${Math.random().toString(36).substring(2, 10).toUpperCase()}`;

  process(job: string) {
    console.log(`[${this.jobId}] Processing job: ${job}`);
    return `${this.jobId} completed ${job}`;
  }

  getJobId() {
    return this.jobId;
  }
}

This service always generates a fresh job ID per injection.

Use them in the Controller

Create a controller in the audit module:

nest g controller audit/audit

audit.controller.ts

import { Controller, Get, Req } from '@nestjs/common';
import { Request } from 'express';
import { GlobalAuditService } from './global-audit.service';
import { RequestTrackerService } from './request-tracker.service';
import { JobProcessorService } from './job-processor.service';

@Controller('audit')
export class AuditController {
  constructor(
    private globalAudit: GlobalAuditService,
    private requestTracker: RequestTrackerService,
    private jobProcessor: JobProcessorService,
  ) {}

  @Get('track')
  trackAction(@Req() request: Request) {
    const user = request.headers['x-user'] || 'anonymous';

    this.requestTracker.setActionBy(user as string);
    this.globalAudit.logEvent(`User ${user} hit track endpoint`);

    const paymentJob = this.jobProcessor.process(`payment for ${user}`);

    return {
      requestId: this.requestTracker.getRequestId(),
      actionBy: this.requestTracker.getActionBy(),
      processedJob: paymentJob,
    };
  }
}

Call the Endpoint

Run local server:

npm run start:dev

Now send requests:

GET /context  with header  x-user: Hamza
GET /context  with header  x-user: Ali

What you’ll observe:

  • Each request has a different requestId (because Request scope)
  • Each job process creates a different jobId even inside same request (Transient scope)
  • The audit logger stays the same instance and runs globally (Singleton)

This is exactly how seniors design isolated request tracking + shared utilities.

Why we choose one scope over another in production

A few powerful real-world scenarios:

Use Request-Scoped when

  • You need to keep the current user identity temporarily
  • You want request-level logs without cross-request data overlap
  • You are implementing multi-tenant logic in future
  • You are building analytics, telemetry, or audit streams per request

Use Singleton when

  • The service is stateless
  • You want the highest performance
  • You want to share data safely across the app
  • You need it inside multiple injectable contexts like guards, modules, or interceptors

Use Transient when

  • Each operation must generate a new instance
  • You need 100% isolation
  • You are building unique processors, transactions, or worker objects
  • You want instances that never share state even within the same request

Senior Tips You Should Lock in Your Mind

  • Make services Singleton unless isolation is required
  • Request scope is powerful but heavier — use only for real request context needs
  • Transient is the most isolated but avoid in frequently used services
  • You can safely inject singleton services inside request-scoped services
  • Scopes are the key to preventing “shared state bugs” that crash multi-user apps

Final Words

Provider scopes are not just a configuration… they are a design decision that protects your app from state pollution, memory issues, and unstable behavior.

Once you understand them, you write backends that behave predictably — even under millions of requests, multi-user traffic, or future distributed architecture.

Related Stories



Performance Optimization of NestJS Applications: A Complete Guide

Performance Optimization of NestJS Applications: A Complete Guide

Optimizing the performance of a NestJS application is critical for building scalable, fast, and production-ready APIs. Even though NestJS is a high-performance framework, improper coding practices, unoptimized database queries, and lack of caching can slow down your application.

Bordered avatar

Street Learner

21 Jan 2025

Mastering High-Performance Interceptors in NestJS for Scalable APIs

Mastering High-Performance Interceptors in NestJS for Scalable APIs

NestJS interceptors are one of the most powerful tools in the framework, enabling developers to transform responses, cache results, log performance, and optimize requests. For large-scale applications, building high-performance interceptors is essential to improve speed, maintainability, and scalability.

Bordered avatar

Street Learner

18 Jan 2025