Skip to content

Provider scopes and request context: singleton fields and AsyncLocalStorage

Never park a request's value on a singleton; carry it in the request's own AsyncLocalStorage store.

Predict first

Before you read: predict

ExamHall's results API serves several universities from one NestJS app. A developer keeps the current university on a service, so the repository can scope its query:

Two GET /results/econ-201 requests arrive, the first with x-university: u-1, then one with x-university: u-2. The first request's rows() call is still waiting when the second request arrives and calls rows(). Then the database answers the first call, then the second.

Whose rows does each request get back?

Code for this question
import { Controller, Get, Headers, Injectable, Module, Param } from '@nestjs/common'; @Injectable()export class TenantContext {  universityId = '';} @Injectable()export class ResultsDb {  async rows(examId: string) {    return [{ university: 'u-1', examId, score: 71 }, { university: 'u-2', examId, score: 64 }];  }} @Injectable()export class ResultsRepository {  constructor(private ctx: TenantContext, private db: ResultsDb) {}  async forExam(examId: string) {    const rows = await this.db.rows(examId);    return rows.filter((r) => r.university === this.ctx.universityId);  }} @Controller('results')export class ResultsController {  constructor(private ctx: TenantContext, private repo: ResultsRepository) {}  @Get(':exam') list(@Param('exam') exam: string, @Headers('x-university') university: string) {    this.ctx.universityId = university;    return this.repo.forExam(exam);  }} @Module({ controllers: [ResultsController], providers: [TenantContext, ResultsDb, ResultsRepository] })export class AppModule {}
Pick one

The 30-second version

A default provider is one object that every request shares, and requests overlap at every await. A request value stored on a singleton's field is overwritten by the next request before the first one reads it back. AsyncLocalStorage gives each request its own store instead, read from anywhere below the request, with every provider still a singleton.

Never park a request's value on a singleton; carry it in the request's own AsyncLocalStorage store.

How it works

Step 1 of 14

One object, every request. A default provider is built once at startup and shared by every request (pat-dip-di-nestjs step 11).

Analogy: A candle maker's workshop and its cooling moulds (candle making)

A candle maker pours candles to order. Each poured candle sets in its mould before it comes back to the bench for its label.

In the workshop In NestJS
one chalkboard over the bench, "label for: ___", rewritten for each order a field on a singleton (step 3)
while candle 1 sets in its mould, the maker pours candle 2 and rewrites the board the next request runs while the first waits (step 2)
candle 1 comes out of its mould; the maker reads the board and sticks on candle 2's label the waiting request reads the other request's value (step 4)
an order card clipped to each mould, cooling with it each request's own store (steps 7–8)
the clipped card is the customer's order itself, not a photocopy: a line crossed out on it is crossed out on the order run keeps a reference to the store, not a copy (step 10)
one batch of wax dyed per gift-shop customer and kept for its later orders a durable provider per tenant (step 13)

In real terms: the board belongs to the bench, the card belongs to the mould.

Breaks down when: the maker glances at the mould's card before labelling. Code never re-checks; it trusts the field.

Contrasting case: a maker who finishes each candle before pouring the next never mixes labels (step 5), until the workshop buys more moulds.

Examples, run for real

4.1 ExamHall EXH-3740 and MR !4040: the RLS variable on a singleton

ExamHall's Postgres policies read app.university_id. The repository's query helper (standing in for the Prisma extension) runs set_config('app.university_id', …) before each query. In EXH-3740 the controller stored the university on a singleton TenantContext; the helper read it after awaiting a pooled connection:

// EXH-3740
this.ctx.universityId = university;                                         // in the controller
const conn = await this.pool.connect();                                     // in the helper
await conn.query(`select set_config('app.university_id', $1, true)`, [this.ctx.universityId]);
// MR !4040
tenantStore.run({ universityId: String(req.headers['x-university']) }, () => next());   // middleware
await conn.query(`select set_config('app.university_id', $1, true)`, [tenantStore.getStore()!.universityId]);
4.1 EXH-3740 u-1's request held at pool.connect() until u-2's arrives: set_config app.university_id u-1's request u-2 · u-2's request u-2 · u-1's reply holds u-2's results
4.1 MR !4040 same overlap, a store per request: set_config u-1's request u-1 · u-2's request u-2 · each reply holds its own university's results · instances built after 2 requests: RlsQuery 1 · ResultsController 1 · TenantMiddleware 1

u-1's query ran under u-2's policy: the field was overwritten while u-1 waited for its connection (steps 3–4). MR !4040 has middleware open a store for each request and the helper read it when it sets the variable; every provider stays a singleton, so nothing is rebuilt per request (steps 7, 8, 12).

Picture it

Concept illustration for this lesson.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

A payments ledger's NestJS app reads the ledger from a store that middleware fills for each request:

The app starts, then serves GET /balances/acc-9 with x-ledger: l-1, and after that reply, GET /balances/acc-9 with x-ledger: l-2.

Which ledgerId does each reply carry?

Code for this question
import { AsyncLocalStorage } from 'node:async_hooks';import type { IncomingMessage, ServerResponse } from 'node:http';import { Controller, Get, Injectable, MiddlewareConsumer, Module, NestMiddleware, NestModule, Param } from '@nestjs/common'; export const ledgerContext = new AsyncLocalStorage<{ ledgerId: string }>(); @Injectable()export class LedgerMiddleware implements NestMiddleware {  use(req: IncomingMessage, res: ServerResponse, next: () => void) {    ledgerContext.run({ ledgerId: String(req.headers['x-ledger']) }, () => next());  }} @Injectable()export class EntriesDb {  async entries(account: string) {    return [{ ledger: 'l-1', account, cents: 500 }, { ledger: 'l-2', account, cents: 900 }];  }} @Injectable()export class BalanceService {  private readonly ledgerId = ledgerContext.getStore()?.ledgerId ?? 'none';  constructor(private db: EntriesDb) {}  async balance(account: string) {    const rows = await this.db.entries(account);    const cents = rows.filter((r) => r.ledger === this.ledgerId).reduce((sum, r) => sum + r.cents, 0);    return { ledgerId: this.ledgerId, cents };  }} @Controller('balances')export class BalancesController {  constructor(private balances: BalanceService) {}  @Get(':account') get(@Param('account') account: string) { return this.balances.balance(account); }} @Module({ controllers: [BalancesController], providers: [EntriesDb, BalanceService] })export class AppModule implements NestModule {  configure(consumer: MiddlewareConsumer) { consumer.apply(LedgerMiddleware).forRoutes(BalancesController); }}
Pick one

Question 2 of 6

A streaming app resumes a video where the viewer stopped. Its middleware puts the viewer in a store:

Two GET /resume/v-7 requests arrive, the first with x-viewer: p-1, then one with x-viewer: p-2. The first request's position() call is still waiting when the second request arrives and calls position(). Then the database answers the first call, then the second.

Which viewerId does each reply carry?

Code for this question
import { AsyncLocalStorage } from 'node:async_hooks';import type { IncomingMessage, ServerResponse } from 'node:http';import { Controller, Get, Injectable, MiddlewareConsumer, Module, NestMiddleware, NestModule, Param } from '@nestjs/common'; export const viewerContext = new AsyncLocalStorage<{ viewerId: string }>();const current = { viewerId: '' }; @Injectable()export class ViewerMiddleware implements NestMiddleware {  use(req: IncomingMessage, res: ServerResponse, next: () => void) {    current.viewerId = String(req.headers['x-viewer']);    viewerContext.run(current, () => next());  }} @Injectable()export class PlaybackDb {  async position(videoId: string) { return videoId === 'v-7' ? 1260 : 0; }} @Injectable()export class ResumeService {  constructor(private db: PlaybackDb) {}  async resume(videoId: string) {    const seconds = await this.db.position(videoId);    return { viewerId: viewerContext.getStore()?.viewerId ?? 'none', seconds };  }} @Controller('resume')export class ResumeController {  constructor(private resumes: ResumeService) {}  @Get(':video') get(@Param('video') video: string) { return this.resumes.resume(video); }} @Module({ controllers: [ResumeController], providers: [PlaybackDb, ResumeService] })export class AppModule implements NestModule {  configure(consumer: MiddlewareConsumer) { consumer.apply(ViewerMiddleware).forRoutes(ResumeController); }}
Pick one

Question 3 of 6

A chat platform keeps each workspace's messages in that workspace's own database. The team groups requests by workspace with this strategy and marks the database provider durable:

The app starts, then serves six GET /channels/general requests, one after another, with x-workspace set to w-1, w-2, w-1, w-3, w-1 and w-2.

After the sixth reply, how many instances of each class has Nest built?

Code for this question
import { Controller, Get, Injectable, Module, Param, Scope } from '@nestjs/common';import { ContextId, ContextIdFactory, ContextIdStrategy, HostComponentInfo } from '@nestjs/core';import type { IncomingMessage } from 'node:http'; const workspaces = new Map<string, ContextId>(); export class ByWorkspaceStrategy implements ContextIdStrategy<IncomingMessage> {  attach(contextId: ContextId, request: IncomingMessage) {    const workspace = String(request.headers['x-workspace']);    const tree = workspaces.get(workspace) ?? ContextIdFactory.create();    workspaces.set(workspace, tree);    return (info: HostComponentInfo) => (info.isTreeDurable ? tree : contextId);  }}ContextIdFactory.apply(new ByWorkspaceStrategy()); @Injectable({ scope: Scope.REQUEST, durable: true })export class WorkspaceDb {  messages(channel: string) { return [`${channel}: hello`]; }} @Injectable()export class ChannelService {  constructor(private db: WorkspaceDb) {}  history(channel: string) { return this.db.messages(channel); }} @Controller('channels')export class ChannelsController {  constructor(private channels: ChannelService) {}  @Get(':channel') get(@Param('channel') channel: string) { return this.channels.history(channel); }} @Module({ controllers: [ChannelsController], providers: [WorkspaceDb, ChannelService] })export class AppModule {}
Pick one

Question 4 of 6

A ticket agency's NestJS app writes an audit line for each seat an agent releases. Middleware puts the agent in a store, and one audit service collects the lines:

The team's test sends GET /release/B3 with x-agent: a-1, and after that reply GET /release/B4 with x-agent: a-2; each reply lists only its own seat, released by its own agent, and the test passes. In production the same two requests overlap: the first request's free() call is still waiting when the second request arrives and calls free(). Then the database answers the first call, then the second.

What does each reply list?

Code for this question
import { AsyncLocalStorage } from 'node:async_hooks';import type { IncomingMessage, ServerResponse } from 'node:http';import { Controller, Get, Injectable, MiddlewareConsumer, Module, NestMiddleware, NestModule, Param } from '@nestjs/common'; export const agentContext = new AsyncLocalStorage<{ agentId: string }>(); @Injectable()export class AgentMiddleware implements NestMiddleware {  use(req: IncomingMessage, res: ServerResponse, next: () => void) {    agentContext.run({ agentId: String(req.headers['x-agent']) }, () => next());  }} @Injectable()export class AuditLog {  private pending: Array<() => string> = [];  queue(line: () => string) { this.pending.push(line); }  flush() {    const lines = this.pending.map((line) => line());    this.pending = [];    return lines;  }} @Injectable()export class SeatsDb {  async free(seat: string) { return seat.length > 0; }} @Controller('release')export class ReleaseController {  constructor(private audit: AuditLog, private db: SeatsDb) {}  @Get(':seat') async release(@Param('seat') seat: string) {    this.audit.queue(() => `${seat} released by ${agentContext.getStore()?.agentId ?? 'none'}`);    await this.db.free(seat);    return this.audit.flush();  }} @Module({ controllers: [ReleaseController], providers: [AuditLog, SeatsDb] })export class AppModule implements NestModule {  configure(consumer: MiddlewareConsumer) { consumer.apply(AgentMiddleware).forRoutes(ReleaseController); }}
Pick one

Question 5 of 6

A box office's NestJS app:

At a ticket release, buyer b-1 holds seat A6 (GET /holds/A6), and after that reply buyer b-2 holds seat A7, both for event e-5. The second reply reads e-5/A7 held for b-1.

Which line is the bug?

Code for this question
import { AsyncLocalStorage } from 'node:async_hooks';import type { IncomingMessage, ServerResponse } from 'node:http';import { Controller, Get, Injectable, MiddlewareConsumer, Module, NestMiddleware, NestModule, Param } from '@nestjs/common'; export const buyerContext = new AsyncLocalStorage<{ buyerId: string; eventId: string }>();const holdsPerSeat = new Map<string, number>(); @Injectable()export class BuyerMiddleware implements NestMiddleware {  use(req: IncomingMessage, res: ServerResponse, next: () => void) {    buyerContext.run({ buyerId: String(req.headers['x-buyer']), eventId: String(req.headers['x-event']) }, () => next());  }} @Injectable()export class SeatsDb {  async lock(seat: string) { return seat.length > 0; }} @Injectable()export class HoldService {  private buyer?: string;  constructor(private db: SeatsDb) {}  private buyerId() {    return (this.buyer ??= buyerContext.getStore()!.buyerId);  }  async hold(seat: string) {    const buyer = this.buyerId();    holdsPerSeat.set(seat, (holdsPerSeat.get(seat) ?? 0) + 1);    await this.db.lock(seat);    const { eventId } = buyerContext.getStore()!;    return `${eventId}/${seat} held for ${buyer}`;  }} @Controller('holds')export class HoldsController {  constructor(private holds: HoldService) {}  @Get(':seat') hold(@Param('seat') seat: string) { return this.holds.hold(seat); }} @Module({ controllers: [HoldsController], providers: [SeatsDb, HoldService] })export class AppModule implements NestModule {  configure(consumer: MiddlewareConsumer) { consumer.apply(BuyerMiddleware).forRoutes(HoldsController); }}
Pick one

Question 6 of 6

A payments app reads the merchant three ways in one handler, to compare them:

Two GET /payouts/acc-3 requests arrive, the first with x-merchant: m-1, then one with x-merchant: m-2. The first request's pending() call is still waiting when the second request arrives and calls pending(). Then the database answers the first call, then the second.

In m-1's reply, which values are m-1?

Code for this question
import { AsyncLocalStorage } from 'node:async_hooks';import type { IncomingMessage, ServerResponse } from 'node:http';import { Controller, Get, Headers, Injectable, MiddlewareConsumer, Module, NestMiddleware, NestModule, Param, Scope } from '@nestjs/common'; export const freshContext = new AsyncLocalStorage<{ merchantId: string }>();export const sharedContext = new AsyncLocalStorage<{ merchantId: string }>();const sharedStore = { merchantId: '' }; @Injectable()export class MerchantMiddleware implements NestMiddleware {  use(req: IncomingMessage, res: ServerResponse, next: () => void) {    const merchantId = String(req.headers['x-merchant']);    sharedStore.merchantId = merchantId;    freshContext.run({ merchantId }, () => sharedContext.run(sharedStore, () => next()));  }} @Injectable({ scope: Scope.REQUEST })export class MerchantHolder {  merchantId = '';} @Injectable()export class PayoutsDb {  async pending(account: string) { return account.length; }} @Controller('payouts')export class PayoutsController {  constructor(private holder: MerchantHolder, private db: PayoutsDb) {}  @Get(':account') async get(@Param('account') account: string, @Headers('x-merchant') merchant: string) {    this.holder.merchantId = merchant;    await this.db.pending(account);    return {      requestScoped: this.holder.merchantId,      fresh: freshContext.getStore()?.merchantId ?? 'none',      shared: sharedContext.getStore()?.merchantId ?? 'none',    };  }} @Module({ controllers: [PayoutsController], providers: [MerchantHolder, PayoutsDb] })export class AppModule implements NestModule {  configure(consumer: MiddlewareConsumer) { consumer.apply(MerchantMiddleware).forRoutes(PayoutsController); }}
Pick one

Go deeper

Pro tip
  1. Test request context with two overlapping requests: hold the first request's database call until the second has arrived, then release them. One request at a time cannot fail. When: a change stores a user, tenant or locale anywhere a request can reach (steps 2–5). Verify:
    tip 1 EXH-3740 code, two requests one after another: replies u-1 | u-2 · test passes true
    tip 1 EXH-3740 code, two requests overlapped: replies u-2 | u-2 · test passes false
Sources

Both are vendor documentation that changes with releases, so their claims are labelled as of 2026-10-13; every behaviour this lesson grades is also executed on NestJS 12.1.2 and Node 24.

Checked against the tools and docs as of 2026-10-13.