The Concurrency Mystery in EF Core Reads

In computer science, a common principle is that concurrent read operations are safe because reading an object does not mutate its state. When working with Entity Framework Core (EF Core), developers often assume this principle applies to query operations like ToListAsync(). After all, fetching data should only involve reading records, right?

However, running two asynchronous queries concurrently on the same context instance results in the familiar runtime exception:

System.InvalidOperationException: A second operation started on this context before a previous operation completed. This is usually caused by different threads using the same instance of DbContext, however instance members are not guaranteed to be thread safe.

So why does EF Core disallow parallel reads? Does reading data actually modify the internal state of a DbContext? Let's dive under the hood to see why this happens and how to correctly run parallel queries.

1. The Underlying Database Connection Is Strictly Sequential

The biggest reason EF Core prevents concurrent reads has nothing to do with EF Core itself, but rather with the underlying ADO.NET connection (such as SqlConnection, NpgsqlConnection, etc.).

A database connection operates over a single network socket. When a query is initiated, that socket is busy sending the command and streaming back results row by row via a data reader (such as DbDataReader). You cannot send another command or start another reader on the same connection while an existing data reader is still active. Attempting to multiplex independent queries over a single, active socket stream without an established protocol causes immediate socket protocol corruption.

2. Reading DOES Mutate the State of DbContext

Contrary to common belief, executing a query in EF Core is not a pure, read-only operation from the perspective of the application memory. Here is what actually happens inside DbContext during a read:

  • Change Tracker Mutation: Unless you explicitly configure your query with .AsNoTracking(), EF Core instantiates objects and attaches them to its internal ChangeTracker. The tracker registers entity keys, builds relationship graphs, and creates snapshot copies to track future mutations. This heavily modifies internal dictionaries and state managers.
  • Connection State: The context manages the state of the underlying database connection (opening, closing, or holding transactions).
  • Internal Cache Updates: Query compilation, parameter evaluation, and model mapping involve internal caches that are updated as queries are translated and materialized.

3. The Cost of Thread Safety

Could the EF Core team have made DbContext thread-safe by locking internal resources and queuing requests? Technically, yes. However, doing so would incur substantial performance overhead (synchronization locks, mutexes, memory barriers) for every single database operation.

Because the vast majority of web requests operate in a single-threaded workflow per HTTP request (via scoped lifetime in ASP.NET Core), paying the performance penalty of thread safety on every call would slow down high-throughput applications unnecessarily. EF Core deliberately chooses speed and low overhead over thread safety.

How to Execute Queries in Parallel Correctly

If you genuinely need to execute multiple queries in parallel (for example, gathering data from several independent tables to populate a dashboard), you have two main options:

Approach 1: Execute Queries Sequentially

In many cases, executing queries sequentially with await is fast enough and requires no extra infrastructure:

var names = await _context.Entities.Select(e => e.PropertyName).ToListAsync();
var entities = await _context.Entities.ToListAsync();

Approach 2: Use IDbContextFactory for True Concurrency

If you need queries to run concurrently on separate threads, use IDbContextFactory<TContext>. This factory pattern creates isolated DbContext instances that each own their own database connection:

// In Program.cs / Startup.cs
builder.Services.AddDbContextFactory<AppDbContext>(
    options => options.UseSqlServer(connectionString));

// In your service or repository
public class DashboardService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public DashboardService(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    public async Task LoadDashboardDataAsync()
    {
        var task1 = Task.Run(async () =>
        {
            using var context = _contextFactory.CreateDbContext();
            return await context.Users.AsNoTracking().ToListAsync();
        });

        var task2 = Task.Run(async () =>
        {
            using var context = _contextFactory.CreateDbContext();
            return await context.Orders.AsNoTracking().ToListAsync();
        });

        await Task.WhenAll(task1, task2);
    }
}

Summary

While reading data may not modify the database records, it does modify internal DbContext state, and ADO.NET connections cannot stream multiple commands concurrently on a single connection. To avoid race conditions and InvalidOperationException, always await your operations sequentially or spin up separate DbContext instances via IDbContextFactory for parallel workloads.