open atlas
↑ Back to track
Backend Architecture BE · 02 · 04

DI scopes and lifecycles: singleton, request, transient

A wired object has a lifetime: created once, once per request, or once per injection. Choosing wrong is two opposite bugs — a singleton holding per-request state leaks data between users, while request scope quietly recreates half your graph on every call.

BE Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

A bug report says user A occasionally sees user B’s account name. The auth code is correct. The leak is a currentUser field on a service that was registered as a singleton — one instance shared by every concurrent request. Request A sets the field, request B overwrites it a millisecond later, and request A reads B’s value. Nobody wrote a security bug. Someone chose the wrong lifetime.

Three lifetimes

A DI container can create an instance at three cadences:

  • Singleton — one instance for the whole process, created once and shared by every request. The default in NestJS, Spring, and most containers. Fast and memory-light, because nothing is recreated.
  • Request-scoped — one instance per incoming request, shared within that request, discarded after. Useful for per-request context (the current user, a request ID, a per-request transaction).
  • Transient — a fresh instance every time it is injected. Useful for stateful helpers that must not be shared at all.

Together these three lifetimes map the full cost spectrum: singleton amortizes construction to zero per request, request scope pays one allocation per request, and transient pays one per injection site. The wrong choice in either direction — singleton holding state, or request scope bubbling — turns that cost into a correctness bug.

The singleton-with-state trap

The Hook’s bug is the canonical singleton mistake: a singleton must be stateless with respect to a request, because it is shared across all concurrent requests. Storing this.currentUser on a singleton is a data-leak waiting to happen — concurrent requests race on the shared field. The fix is either to make the per-request data flow through method arguments (pass user in, do not store it) or to make the service request-scoped so each request gets its own instance.

The opposite trap: scope bubbling

Request scope looks like the safe default, so teams reach for it — and pay a hidden cost. Scope bubbles up the injection chain: if a request-scoped provider is injected into a controller, that controller becomes request-scoped too, and so does anything that depends on it. One request-scoped logger near the bottom can “infect” the entire graph above it, so the container reinstantiates a large slice of your services on every single request instead of reusing singletons. That is extra allocation, extra GC pressure, and measurable latency — for what is often just a convenience.

Put numbers on it. A singleton is constructed once, ever — zero per-request allocations on the wiring axis. Flip one low-level provider to request scope and bubble it through a 10-deep controller subgraph, and now the container runs ~10 constructor calls and allocates ~10 objects per request. At 5,000 req/s that is 50,000 short-lived objects per second the GC must reclaim; in a NestJS service this commonly shows up as a few hundred microseconds to ~1ms added at p99 and a visibly busier young-generation GC. Worse is the request-scoped leak: if a request-scoped provider registers a listener, opens a handle, or pushes into a module-level array and nothing tears it down at request end, each request leaves residue behind. A handler that subscribes to an EventEmitter per request without unsubscribing can grow heap on the order of tens of MB/hour under steady load — a slow leak that looks fine in a 60-second local test and OOM-kills the pod at 3 a.m. The senior reflex: request scope is not “free safety,” it is a per-request allocation budget and a teardown obligation.

Why this works

Why does request scope bubble but transient does not? Scope bubbling is about correctness of sharing. If service A is request-scoped (one instance per request) and controller C injects A, then C cannot be a singleton — a single shared C would have to hold a reference to some A, but there is a different A per request. So C must also be created per request; the request lifetime propagates up to every consumer. Transient is different: a singleton can safely hold a transient dependency because the transient was created fresh at injection time and the singleton simply keeps that one instance. Transient changes how many instances exist, not how long the consumer must live, so it does not force its consumers to a shorter lifetime.

Picking the lifetime

The rule that avoids both traps: default to singleton and keep singletons stateless; reach for request scope only when you genuinely need per-request identity that cannot be passed as an argument; use transient sparingly for deliberately unshared stateful helpers. Most “I need the current user here” cases are better solved by passing it through the call than by making the service request-scoped and dragging the whole graph down with it.

ScopeInstancesBest forTrap
SingletonOne per processStateless services, clients, poolsHolding per-request state → data leak
RequestOne per requestCurrent user, request transactionBubbles up, recreates the graph per request
TransientOne per injectionUnshared stateful helpersSurprising count of instances
Quiz

A singleton service stores `this.currentUser` and occasionally one user sees another's data. Why?

Quiz

Why can making one low-level provider request-scoped hurt performance across many services?

Quiz

A singleton needs the current request's user. What is the cleanest fix that avoids both traps?

Recall before you leave
  1. 01
    What are the three DI lifetimes and what is each best for?
  2. 02
    Why is storing per-request state on a singleton a data-leak bug, and how is it fixed?
  3. 03
    Explain scope bubbling and why request scope can be expensive while transient is not.
Recap

Every object the container wires has a lifetime, and the choice is a correctness-and-cost decision. Singleton — one instance per process — is the default and the right home for stateless services, clients, and pools, but storing per-request state on one leaks data across concurrent users, the canonical singleton bug. Request scope gives each request its own instance and is the honest fix for genuine per-request identity, but it bubbles up the injection chain: one request-scoped provider can force its consumers, and theirs, to be recreated on every request, trading reuse for allocation and latency. Transient creates a fresh instance per injection without forcing consumers to a shorter lifetime. The discipline is to default to stateless singletons and pass per-request values as arguments, escalating scope only when there is no cleaner option. Lifetimes set up the next concern: with the graph wired and scoped, how DI becomes the seam that makes the system testable. Now when you see a data-leak bug or a latency spike that only appears under load, check scope first — a singleton holding mutable per-request state or a request-scoped provider quietly infecting the graph above it accounts for more incidents than most engineers expect.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons
appears again in188

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.