DEV Community

Cover image for "A second operation was started on this context" — the EF Core bug that only shows up under load
Imran Ahmed
Imran Ahmed

Posted on

"A second operation was started on this context" — the EF Core bug that only shows up under load

"A Second Operation Was Started on This Context" — The EF Core Bug That Only Shows Up Under Load

You’ve debugged this error before:

InvalidOperationException: A second operation was started on this context before a previous operation completed.
Enter fullscreen mode Exit fullscreen mode

It’s frustrating because it works locally but fails under load. The root cause isn’t what you might think—it’s not about explicit Task.WhenAll calls or parallel loops. It’s a service-lifetime design issue that only surfaces when concurrency is real.

The Problem: A Fire-and-Forget Context Leak

In a hosted background service, a single scoped DbContext was being reused across parallel work items. Locally, everything appeared fine—until production load hit. The issue wasn’t the DbContext itself, but what was holding onto it.

A fire-and-forget task (e.g., Task.Run or BackgroundService logic) was capturing a reference to the DbContext after its scope had expired. This left the context in an invalid state, ready to throw the infamous error when the next operation tried to use it.

Why It’s Hidden Until Load Arrives

  1. Local testing lacks parallelism: The bug only manifests when multiple threads access the context asynchronously.
  2. Intermittent errors: The context’s internal state gets corrupted over time, making the error appear randomly.
  3. Fire-and-forget tasks are easy to overlook: They’re often "out of sight, out of mind" in debugging.

The Misconception: EF Core Thread Safety

Many developers assume EF Core’s thread-safety rules apply to explicit parallelism (e.g., Task.WhenAll). But that’s not the case. EF Core’s thread-safety is about lifetime:

  • A DbContext must live only within its scoped lifetime.
  • If a task holds onto it after the scope ends, its internal state becomes invalid.

The Correct Approach: One Scope Per Unit of Work

The fix isn’t about "fixing" the error—it’s about designing for lifetime correctness. Here’s how to do it right:

1. Resolve the DbContext Inside the Scope

  • In a background service, resolve the context per task, not per service instance.
  • Example:

     protected override async Task ExecuteAsync(CancellationToken stoppingToken)
     {
         while (!stoppingToken.IsCancellationRequested)
         {
             await using var scope = _serviceProvider.CreateAsyncScope();
             var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    
             // Use the context here—it’s scoped to this task.
             await ProcessWorkItemAsync(context);
    
             // Context is disposed when the scope ends.
         }
     }
    

2. Avoid Fire-and-Forget Tasks Holding the Context

  • If you must use fire-and-forget, ensure the context is not captured in the task’s closure.
  • Example of bad (context leaks):

     // ❌ BAD: Captures the context after scope ends.
     Task.Run(async () => await ProcessAsync(context));
    
  • Example of good (context is scoped):

     // ✅ GOOD: Resolve the context inside the task's scope.
     await Task.Run(async () =>
     {
         await using var scope = _serviceProvider.CreateAsyncScope();
         var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
         await ProcessAsync(context);
     });
    

3. Use IServiceScope Explicitly

  • Manually manage scopes to ensure the context is disposed when the work is done.
  • Example:

     await using var scope = _serviceProvider.CreateAsyncScope();
     var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
     await context.SaveChangesAsync(); // Safe—context is scoped.
    

Why This Matters

This isn’t just about fixing an error—it’s about designing for parallelism. EF Core’s concurrency rules are about respecting dependency boundaries. If you violate those boundaries (e.g., by letting a DbContext outlive its scope), the errors will follow.

Practical Takeaway

  • Test under load: Concurrency bugs hide until they’re under pressure.
  • Design for lifetime: A DbContext must live only within its scope.
  • Avoid fire-and-forget leaks: Ensure no task holds onto the context after the scope ends.

By following these principles, you’ll avoid the "works locally but fails in production" anti-pattern and build more robust, scalable applications.

Top comments (0)