I once spent the better part of a week chasing a bug that only appeared in our staging environment under heavy load. A user from one organization would suddenly see data belonging to another. We’d check the logs, query the database directly, and everything looked fine. It was a ghost.
The culprit? A single line change someone made to “optimize” our database access: switching AddDbContext
to AddDbContextPool
. That optimization introduced a state-sharing bug that was nearly impossible to reproduce locally.
DbContext
pooling in EF Core is one of those features that promises a big performance win, but it comes with a massive, sneaky gotcha. Let’s break down what it is, where it shines, and how to use it without shooting yourself in the foot.
So, what’s DbContext
pooling anyway?
Normally, when you register your DbContext
in an ASP.NET Core app, you use a scoped lifetime. This is the default and the safest option.
// The standard, safe way: A new instance for every HTTP request.
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
This means for every single web request, the dependency injection container creates a brand-new DbContext
instance. When the request is over, that instance is thrown away. Simple, clean, and isolated.
DbContext
pooling changes the game. Instead of creating a new instance every time, it keeps a “pool” of DbContext
instances ready to go.
// The high-performance way: Reuses instances from a pool.
services.AddDbContextPool<AppDbContext>(options =>
options.UseSqlServer(connectionString));
When a request needs a DbContext
, it borrows one from the pool. When it’s done, the context isn’t destroyed. EF Core just calls a ResetState
method on it and returns it to the pool, ready for the next request. Think of it like a reusable grocery bag instead of a single-use plastic one.
Does it actually make a difference?
In a low-traffic app, you won’t notice a thing. But if you’re handling hundreds or thousands of requests per second, the cost of creating and garbage-collecting thousands of DbContext
objects adds up fast. That’s where pooling pays off.
Here’s some data from a load test on a production API. The difference is clear.
Load Scenario | Memory Allocation (No Pool) | Memory Allocation (Pooled) | Avg. Response Time Reduction |
---|---|---|---|
500 req/sec | 85 MB/s | 61 MB/s | 8ms |
1,500 req/sec | 180 MB/s | 125 MB/s | 15ms |
3,000 req/sec | 340 MB/s | 230 MB/s | 22ms |
Reducing memory allocation is the big win here. Less memory pressure means the garbage collector runs less often, which leads to more consistent and lower latency. This one burned me once in production: an API endpoint had a p99 latency spike every 30 seconds, which we traced back to Gen 2 garbage collections. Pooling helped smooth that out.
The Big Gotcha: Shared State
Here’s the dangerous part. When EF Core “resets” a DbContext
instance, it only resets the state it knows about, like the ChangeTracker
. It does not touch any fields or properties you’ve added to your DbContext
subclass.
This is where my ghost bug came from. Look at this seemingly innocent DbContext
:
public class OrderDbContext : DbContext
{
// DANGER: With pooling, this state leaks between requests!
public string CurrentTenantId { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
// This query filter will use a STALE TenantId from a previous request.
builder.Entity<Order>().HasQueryFilter(x => x.TenantId == CurrentTenantId);
}
}
If you use AddDbContextPool
with this class, you’re headed for disaster.
- Request A for “tenant-1” gets a context from the pool and sets
CurrentTenantId
. - Request A finishes. The context goes back to the pool, but
CurrentTenantId
is still “tenant-1”. - Request B for “tenant-2” gets that exact same instance. Now, all its queries are secretly filtered for the wrong tenant. Boom. You’re leaking customer data.
The only way to use pooling safely is to ensure your DbContext
is completely stateless.
How to Use It Safely (Without Blowing Up Prod)
A pooled DbContext
should contain nothing but DbSet
properties and configuration overrides. All request-specific data, like user IDs or tenant IDs, must be passed into your methods from other, request-scoped services.
Here’s a setup that’s safe for production.
1. A Clean, Stateless DbContext
Your DbContext
class should be lean. Its only job is to define your data model.
// This is a safe, pool-friendly DbContext. No custom state.
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options) { }
public DbSet<Order> Orders { get; set; }
public DbSet<Customer> Customers { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
// Your entity configurations go here.
}
}
2. Correct Dependency Injection
Register the pool in Program.cs
. You can also configure the pool size, though the default (128) is usually fine.
// In Program.cs
builder.Services.AddDbContextPool<ApplicationDbContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
}, poolSize: 128);
3. Inject Services Where They’re Used
Never inject a request-scoped service (like IHttpContextAccessor
or a user service) into your pooled DbContext
’s constructor. That creates a “captive dependency” and will reuse a stale service instance.
Instead, inject both the context and the scoped service into the class that needs them, like your repository or application service.
// DO NOT DO THIS
public class BadDbContext : DbContext
{
private readonly ICurrentUser _currentUser; // This is a scoped service
// This constructor captures a single instance of ICurrentUser and reuses it. BAD!
public BadDbContext(DbContextOptions options, ICurrentUser currentUser) : base(options)
{
_currentUser = currentUser;
}
}
// DO THIS INSTEAD
public class OrderService
{
private readonly ApplicationDbContext _context;
private readonly ICurrentUser _currentUser;
// Inject the pooled context and the scoped service here.
public OrderService(ApplicationDbContext context, ICurrentUser currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<List<Order>> GetMyOrdersAsync()
{
// Use the current, correct user ID inside the method.
var userId = _currentUser.Id;
return await _context.Orders
.Where(o => o.UserId == userId)
.ToListAsync();
}
}
The Final Takeaway: When to Use It, When to Avoid It
So, should you use it? Here’s my rule of thumb.
Use DbContext
Pooling When:
- You have a high-throughput application (e.g., 1000+ requests/sec) where performance is critical.
- Your
DbContext
is completely stateless (no custom fields, properties, or request-scoped constructor dependencies). - You’ve benchmarked your application and can prove that
DbContext
creation is a bottleneck.
Avoid DbContext
Pooling When:
- Your
DbContext
holds any kind of state (TenantId
,UserId
, etc.). Just don’t. The risk of data leakage is too high. - You’re injecting request-scoped services into your
DbContext
. - You’re working on a low-to-medium traffic application. The standard scoped lifetime is safer, easier to reason about, and plenty fast.
For most projects, the default AddDbContext
is the right choice. DbContext
pooling is a powerful tool for specific high-performance scenarios, but it’s an optimization that demands discipline. If you can’t guarantee your context is stateless, stay away. The performance gain isn’t worth debugging a ghost.
References
- DbContext Pooling - Microsoft EF Core Docs
- Dependency Injection - Microsoft ASP.NET Core Docs
- Stack Overflow: How does Entity Framework’s DbContext & connection pooling work?