Managing EF Core DbContext Lifetime (Without Shooting Yourself in the Foot)

September 16 2025

 
 

Background

 
 

DbContext is the heart of EF Core - but it’s easy to misuse. The big rules:

 

• A DbContext represents a unit of work and should be short-lived.
• It is not thread-safe; never share one instance across concurrent operations.
• In ASP.NET Core, the default and usually correct choice is a scoped DbContext per request. For work outside the request scope (background services, singletons, UI apps), use a factory to create fresh contexts on demand.

 

This post shows how to wire it correctly, when to choose each registration, and the traps to avoid.

 
 

The three registrations you should know

 
 

1. AddDbContext (Scoped - default, per request)


// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
When to use: MVC/Minimal API controllers, Razor Pages, SignalR hubs - anything that lives inside a web request.

 

Why: You get one context per request automatically; EF Core handles connection usage efficiently.

 

2. AddDbContextFactory (Transient factory for on-demand contexts)


builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
How to use it:

public sealed class ReportService(IDbContextFactory<AppDbContext> factory)
{
    public async Task<IReadOnlyList<OrderDto>> GetAsync(CancellationToken ct)
    {
        await using var db = await factory.CreateDbContextAsync(ct);
        return await db.Orders
            .Where(o => o.Status == OrderStatus.Completed)
            .Select(o => new OrderDto(o.Id, o.Total))
            .ToListAsync(ct);
    }
}
When to use:

 

• Background/hosted services (IHostedService, BackgroundService)
• Any singleton service that needs a DbContext
• Desktop/Blazor apps where you want a fresh context per operation

 

Why: Factories create clean, short-lived contexts without relying on ambient scopes.

 

3. AddDbContextPool (Scoped with pooling)


builder.Services.AddDbContextPool<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
When to use: High-throughput APIs when the context configuration is stable and stateless.

 

Why: Reuses DbContext instances from a pool for lower allocation overhead.

 

Caveat: don’t stash per-request state in your context; pooled instances are reset and reused.

 
 

Real-world patterns (copy/paste friendly)

 
 

Controller / Minimal API (Scoped is perfect)


app.MapGet("/orders/{id:int}", async (int id, AppDbContext db, CancellationToken ct) =>
{
    var order = await db.Orders.FindAsync([id], ct);
    return order is null ? Results.NotFound() : Results.Ok(order);
});
Why it works: DI gives you a scoped context bound to the request; one unit-of-work, no leaks.

 

BackgroundService with IServiceScopeFactory (or use the factory directly)


public sealed class CleanupService(IServiceScopeFactory scopes, ILogger<CleanupService> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = scopes.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

            // One short-lived unit of work
            var cutoff = DateTime.UtcNow.AddDays(-30);
            db.RemoveRange(db.AuditLogs.Where(x => x.CreatedAt < cutoff));
            await db.SaveChangesAsync(stoppingToken);

            await Task.Delay(TimeSpan.FromHours(6), stoppingToken);
        }
    }
}
Alternative (my go-to):

public sealed class CleanupService(IDbContextFactory<AppDbContext> factory, ILogger<CleanupService> log) 
: BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            await using var db = await factory.CreateDbContextAsync(ct);
            // same cleanup logic …
            await Task.Delay(TimeSpan.FromHours(6), ct);
        }
    }
}
Why: Hosted services don’t have a request scope; you must create one (or use a factory).

 

Singleton service that needs the database


public sealed class PricingCache(IDbContextFactory<AppDbContext> factory, IMemoryCache cache)
{
    public async Task<decimal> GetPriceAsync(int productId, CancellationToken ct)
    {
        if (cache.TryGetValue(productId, out decimal price)) return price;

        await using var db = await factory.CreateDbContextAsync(ct);
        price = await db.Products
                        .Where(p => p.Id == productId)
                        .Select(p => p.Price)
                        .FirstAsync(ct);

        cache.Set(productId, price, TimeSpan.FromMinutes(10));
        return price;
    }
}
Why: Singletons must not capture scoped DbContext; creating a fresh one per call avoids threading and lifetime bugs.

 

High-throughput APIs (pooling)


builder.Services.AddDbContextPool<AppDbContext>(o =>
{
    o.UseNpgsql(builder.Configuration.GetConnectionString("Default"));
    // Keep options stateless; avoid per-request mutable state
});
Tip: Pooling helps allocation/throughput. It doesn’t make DbContext thread-safe. Still one context per request.

 
 

Five common pitfalls (and fixes)

 
 

• Sharing one DbContext across threads

 

Symptom: “A second operation started on this context before a previous operation was completed.”
Fix: One context per unit of work; never run parallel queries on the same context. Use separate contexts.

 

• Injecting DbContext into singletons

 

Fix: Inject IDbContextFactory or IServiceScopeFactory and create contexts on demand.

 

• Keeping contexts alive too long

 

Fix: Keep them short-lived; long-lived contexts bloat change trackers and retain connections.

 

• Using pooling with per-request state

 

Fix: Don’t put user-specific state on the context (e.g., CurrentUserId field). Pooled contexts are reused.

 

• Trying to “speed up” by running multiple queries concurrently on one context

 

Fix: Either serialize the work or create multiple contexts. DbContext is not thread-safe.

 
 

Performance notes

 
 

• Pooling reduces allocations under heavy load; measure with your workload.
• Thread-safety checks: EF Core can detect some multi-thread misuse; you can disable checks to squeeze perf, but only if you’re absolutely sure no concurrency occurs on the same context. I rarely recommend turning them off.

 
 

Conclusion

 
 

Managing DbContext lifetime correctly is the difference between a stable, high-performance EF Core app and one that randomly throws concurrency errors or quietly leaks memory. The rule of thumb is simple:

 

• Default: AddDbContext (scoped) - one context per request.
• Background/singleton: AddDbContextFactory - create short-lived contexts on demand.
• High throughput: AddDbContextPool - recycle contexts, but only if your configuration is stateless.

 

Think of DbContext as a notepad for a single unit of work: you grab a fresh sheet, write your changes, and toss it away when you’re done.
Don’t pass it around the whole office, and don’t try to write on it with two pens at once.

 

By keeping contexts short-lived, isolated, and created in the right way for your workload, you’ll avoid the classic EF Core pitfalls - while keeping your app fast, predictable, and easy to maintain.

 

That's all from me for today.

There are 3 ways I can help you:

My Design Patterns Ebooks

1. Design Patterns that Deliver

This isn’t just another design patterns book. Dive into real-world examples and practical solutions to real problems in real applications.Check out it here.


1. Design Patterns Simplified

Go-to resource for understanding the core concepts of design patterns without the overwhelming complexity. In this concise and affordable ebook, I've distilled the essence of design patterns into an easy-to-digest format. It is a Beginner level. Check out it here.


Join TheCodeMan.net Newsletter

Every Monday morning, I share 1 actionable tip on C#, .NET & Arcitecture topic, that you can use right away.


Sponsorship

Promote yourself to 17,150+ subscribers by sponsoring this newsletter.



Join 17,150+ subscribers to improve your .NET Knowledge.

Powered by EmailOctopus

Subscribe to
TheCodeMan.net

Subscribe to the TheCodeMan.net and be among the 17,150+ subscribers gaining practical tips and resources to enhance your .NET expertise.

Powered by EmailOctopus