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?
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 {}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
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

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?
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); }}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?
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); }}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?
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 {}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?
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); }}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?
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); }}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?
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); }}Go deeper
Pro tip
- 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.
- NestJS documentation, Injection scopes (the curriculum row's first source): https://docs.nestjs.com/fundamentals/injection-scopes, read from its source file at the file's latest commit on 2026-10-13: https://raw.githubusercontent.com/nestjs/docs.nestjs.com/e0984c784f8546f44d1fa3344a73c7fbf0d56d46/content/fundamentals/provider-scopes.md (
nestjs-injection-scopes.txt). Teaching for steps 1, 6, 12–14. - Node.js documentation, Asynchronous context tracking:
AsyncLocalStorage(the row's second source): https://nodejs.org/docs/latest-v24.x/api/async_context.html, read from the v24.x source file at its latest commit on 2026-10-13: https://raw.githubusercontent.com/nodejs/node/71b8b174857e25106d39b61a9e6f30d927da8b01/doc/api/async_context.md (node-async-context.txt). Teaching for steps 7–10.
Related lessons
Checked against the tools and docs as of 2026-10-13.