Picking the Right Service Lifetime in ASP.NET Core: Singleton, Scoped, or Transient
Singleton, Scoped, or Transient? A concrete, runnable example shows how ASP.NET Core service lifetimes behave, how scoped-from-singleton captures cause cross-request bugs, and how to catch them at startup.
15 Mar 2026, 08:41 UTC

A team I won't name shipped a bug where user A occasionally saw user B's shopping cart. The root cause wasn't a race condition in their logic — it was a single line in Program.cs: a service that held per-request state had been registered as a Singleton. ASP.NET Core's dependency injection (DI) container is easy to use and easy to misuse, and the three lifetimes it offers — Singleton, Scoped, and Transient — are where most of the misuse happens.
The takeaway up front: pick the lifetime based on what the service holds, not on what's convenient to type. Stateless and thread-safe? Singleton. Tied to one HTTP request? Scoped. Cheap and needed fresh every time? Transient. The rest of this post shows how to verify that choice instead of guessing.
What each lifetime actually means
The built-in container creates and caches instances according to the registration:
- Singleton — one instance for the entire application lifetime. Every consumer, every request, gets the same object.
- Scoped — one instance per scope. In ASP.NET Core, the framework creates a scope per HTTP request, so "scoped" effectively means "per request." This is why
DbContextis registered as scoped by default. - Transient — a new instance every time it's requested, even multiple times within the same request.
The container also disposes what it creates (for IDisposable services) when the relevant lifetime ends: scoped services at the end of the request, singletons at application shutdown. Transients are disposed at the end of the scope that resolved them — a detail that matters, as we'll see.
A worked example you can run
The fastest way to build intuition is to watch instance IDs change. In a minimal API project (this assumes .NET 6 or later with the minimal hosting model), register one service per lifetime and stamp each with a GUID:
public class InstanceId { public Guid Id { get; } = Guid.NewGuid(); }
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<InstanceId>();
builder.Services.AddScoped<ScopedId>();
builder.Services.AddTransient<TransientId>();
var app = builder.Build();
app.MapGet("/", (InstanceId singleton, ScopedId scoped,
TransientId t1, TransientId t2) =>
new
{
singleton = singleton.Id,
scoped = scoped.Id,
transient1 = t1.Id,
transient2 = t2.Id
});
app.Run();Run it locally with dotnet run (no special permissions needed) and hit the endpoint twice. Expected behavior: the singleton ID is identical across requests; the scoped ID changes between requests but would be the same for two injections within one request; the two transient IDs differ from each other within the same response. If any of those don't hold, your registration isn't what you think it is. This is a five-minute experiment, and it's worth doing once on a real project with your actual services.
The captive dependency trap
The classic failure mode is injecting a Scoped service into a Singleton. Because the singleton lives forever, it "captures" the scoped instance it received at first resolution — that one DbContext gets reused across every request, long after its scope ended. Depending on timing you get stale data, cross-request state leakage (the shopping cart bug), or ObjectDisposedException.
You don't have to discover this in production. In development, turn on the container's validation:
var builder = WebApplication.CreateBuilder(args);
if (builder.Environment.IsDevelopment())
{
builder.Host.UseDefaultServiceProvider(o =>
{
o.ValidateScopes = true;
o.ValidateOnBuild = true;
});
}ValidateScopes throws when a scoped service is resolved from the root (singleton) provider; ValidateOnBuild checks at startup that every registration can actually be constructed. Together they surface lifetime mismatches at boot instead of at 2 a.m. under load. They're off by default outside development because the checks cost startup time — keep them dev-only.
If a singleton genuinely needs scoped work (a background worker processing queued items, for example), don't inject the scoped service. Inject IServiceScopeFactory and create a scope per unit of work:
public class QueueWorker(IServiceScopeFactory scopeFactory)
{
public async Task ProcessAsync()
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// use db, then let the scope dispose it
}
}Trade-offs worth knowing
Transient isn't free. Every resolution allocates, and every transient the container creates is tracked for disposal until the scope ends. A transient IDisposable resolved fifty times per request means fifty tracked objects per request. For hot paths, prefer a singleton (if stateless) or scoped.
Singleton demands thread safety. Any mutable field on a singleton is shared by all concurrent requests. If you find yourself adding locks to a singleton, ask whether it should have been scoped.
Deep constructor chains hide problems. A singleton that depends on a service that depends on a service that depends on a DbContext still captures that context — validation helps, but keeping registrations explicit and service graphs shallow helps more.
A practical default
When in doubt, start with Scoped for anything that touches request state, Singleton for stateless infrastructure (configuration accessors, HTTP clients via IHttpClientFactory, caches), and Transient only when you can articulate why each consumer needs its own instance. Then verify: run the instance-ID experiment above against your real registrations, and enable ValidateScopes in development permanently. Lifetime bugs are cheap to prevent and expensive to debug — spend the five minutes up front.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.