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 ScenarioMemory Allocation (No Pool)Memory Allocation (Pooled)Avg. Response Time Reduction
500 req/sec85 MB/s61 MB/s8ms
1,500 req/sec180 MB/s125 MB/s15ms
3,000 req/sec340 MB/s230 MB/s22ms

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.

  1. Request A for “tenant-1” gets a context from the pool and sets CurrentTenantId.
  2. Request A finishes. The context goes back to the pool, but CurrentTenantId is still “tenant-1”.
  3. 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

FAQ

Is DbContext pooling always better for performance?

No. It improves memory usage and response time under high load (typically above 500 requests per second), but introduces risks when using request-scoped services or custom state in the DbContext.

Can I use DbContext pooling in multi-tenant apps?

Yes, but with caution. Avoid storing tenant-specific values inside the DbContext. Instead, use transient services like ITenantProvider to isolate tenant context per request.

What happens if I inject scoped services into a pooled DbContext?

This breaks the dependency injection lifetime contract. It can lead to stale data, memory leaks, and unexpected cross-request behavior. Use service location inside methods instead of constructor injection.

How do I monitor if pooling is helping?

Track memory allocation rate, GC collections, DbContext creation time, and pool hit/miss ratios. Use tools like Application Insights, Prometheus, or custom middleware for accurate diagnostics.

What’s a safe pool size?

Start with Environment.ProcessorCount × 2. Increase based on actual request concurrency. Monitor for pool exhaustion or underutilization to tune the optimal size.

About the Author

@CodeCrafter is a software engineer who builds real-world systems , from resilient ASP.NET Core backends to clean, maintainable Angular frontend. With 11+ years in production development, he shares what actually works when shipping software that has to last.