DbContext is Not Thread-Safe: Parallelizing EF Core Queries the Right Way

Most backend teams have built that one endpoint that pulls together several unrelated pieces of data into a single response. Think of a dashboard screen that needs the last fifty orders, the current system health logs, and the user’s account stats, all in one API call. The obvious way to write it is to await each query one after another, and that code is clean and easy to read.

The problem shows up the moment you check the timing. If fetching orders takes 300ms, logs take 400ms, and stats take 300ms, the caller waits a full second even though none of these three queries depend on each other. Since the data sets are unrelated, running them in parallel should bring the total time down to roughly 400ms, the duration of the slowest query alone. That is close to a 60 percent improvement, just from changing how the calls execute.

The instinct is to wrap the three calls in tasks and use Task.WhenAll. With EF Core, that instinct gets you a runtime exception instead of a performance win, and understanding exactly why is what makes the correct fix stick.

Why Task.WhenAll blows up with a shared DbContext

Here is the naive version most developers reach for first. All three repository methods use the same injected DbContext instance, and the calls are fired off together with Task.WhenAll.

// Do not do this
public async Task<DashboardData> GetDashboardData(int userId)
{
    // These methods all use the same injected _dbContext
    var ordersTask = _repository.GetOrdersAsync(userId);
    var logsTask = _repository.GetLogsAsync();
    var statsTask = _repository.GetStatsAsync(userId);
 
    await Task.WhenAll(ordersTask, logsTask, statsTask); // throws
 
    return new DashboardData(ordersTask.Result, logsTask.Result, statsTask.Result);
}

Running this throws an InvalidOperationException with a message along the lines of: a second operation started on this context before a previous operation completed, usually caused by different threads using the same DbContext instance. The reason is that DbContext is a stateful object built around a single unit of work. It keeps a change tracker for every entity you have loaded, and it wraps exactly one underlying database connection.

Database wire protocols, whether it is TCP for PostgreSQL or SQL Server’s tabular data stream, are synchronous at the connection level. You cannot push two separate queries down the same connection at the same instant. When Task.WhenAll fires three operations against one DbContext, they all try to grab that single connection at once, and EF Core throws rather than risk silently corrupting the change tracker or mixing up result sets. So the constraint is real: you get the speed of parallel execution or you get a shared DbContext, not both at the same time.

The fix: IDbContextFactory

Since .NET 5, EF Core ships a purpose built answer to this exact problem: IDbContextFactory<T>. Instead of injecting a scoped DbContext that lives for the whole HTTP request, you inject a factory and use it to create small, independent DbContext instances on demand, one per parallel operation.

Registration happens in Program.cs. AddDbContextFactory registers the factory itself as a singleton by default, and it also registers the DbContext type as scoped so the rest of your code can keep injecting it directly wherever a single context is enough.

// This registers IDbContextFactory<AppDbContext> as a Singleton (by default)
// It also registers AppDbContext as Scoped for ease of use elsewhere
builder.Services.AddDbContextFactory<AppDbContext>(options =>
{
    options.UseNpgsql(builder.Configuration.GetConnectionString("db"));
});

That one line is enough to unlock the pattern everywhere in the app. If you do not use dependency injection for some reason, you can also create a context manually with using var context = new AppDbContext(options), as long as you have access to the DbContextOptions. The factory approach is cleaner for most ASP.NET Core apps, so stick with that unless you have a specific reason not to.

With the factory registered, the dashboard service changes shape. Instead of injecting AppDbContext, it injects IDbContextFactory<AppDbContext>, and each private method that talks to the database creates and disposes its own context.

using Microsoft.EntityFrameworkCore;
 
public class DashboardService(IDbContextFactory<AppDbContext> contextFactory)
{
    public async Task<DashboardDto> GetDashboardAsync(int userId)
    {
        // 1. Start the tasks (queries begin executing immediately)
        var ordersTask = GetOrdersAsync(userId);
        var logsTask = GetSystemLogsAsync();
        var statsTask = GetUserStatsAsync(userId);
 
        // 2. Wait for all to complete
        await Task.WhenAll(ordersTask, logsTask, statsTask);
 
        // 3. Return results (awaiting the finished tasks unwraps them)
        return new DashboardDto(
            await ordersTask,
            await logsTask,
            await statsTask
        );
    }
 
    private async Task<List<Order>> GetOrdersAsync(int userId)
    {
        await using var context = await contextFactory.CreateDbContextAsync();
 
        return await context.Orders
            .AsNoTracking()
            .Where(o => o.UserId == userId)
            .OrderByDescending(o => o.CreatedAt)
            .ThenByDescending(o => o.Amount)
            .Take(50)
            .ToListAsync();
    }
 
    private async Task<List<SystemLog>> GetSystemLogsAsync()
    {
        await using var context = await contextFactory.CreateDbContextAsync();
 
        return await context.SystemLogs
            .AsNoTracking()
            .OrderByDescending(l => l.Timestamp)
            .Take(50)
            .ToListAsync();
    }
 
    private async Task<UserStats?> GetUserStatsAsync(int userId)
    {
        await using var context = await contextFactory.CreateDbContextAsync();
 
        return await context.Users
            .Where(u => u.Id == userId)
            .Select(u => new UserStats { OrderCount = u.Orders.Count })
            .FirstOrDefaultAsync();
    }
}

Notice that GetOrdersAsync, GetSystemLogsAsync, and GetUserStatsAsync each call CreateDbContextAsync before touching the database, so every one of them gets its own connection. The outer method assigns each call to a task variable without awaiting it immediately, which is what lets all three start running before Task.WhenAll blocks for the results. A common mistake here is to write await GetOrdersAsync(userId) directly on that first line, which awaits the call immediately and puts you right back to sequential execution with extra ceremony.

Isolation and disposal are what make this safe

Two things matter for this pattern to work correctly. First, isolation: each task gets its own DbContext, and therefore its own database connection, so there is no contention over a shared connection or change tracker. Second, disposal: the await using statement is not optional decoration, it is what returns the connection to the pool the moment the query finishes.

Skipping await using, or holding contexts open longer than needed, defeats the purpose of the pattern. You end up holding more connections than necessary, which is exactly the resource pressure this technique is supposed to avoid, not introduce. Keep each context scoped tightly to the single query it exists for.

What the benchmark actually shows

A local benchmark app built on .NET 10, Aspire, and PostgreSQL puts sequential execution at roughly 36ms and the parallel version at roughly 13ms for the same three queries. Because this ran locally, the absolute numbers are small. Against a remote database with real network latency, the raw milliseconds would be higher, but the ratio between sequential and parallel would hold up similarly.

Distributed trace of the sequential version: each query waits for the previous one to finish.
Distributed trace of the sequential version: each query waits for the previous one to finish.

The sequential trace shows a clear waterfall, one span starting only after the previous one ends. That shape is the direct cost of awaiting each query one at a time inside a single method, even though the three queries have nothing to do with each other.

Distributed trace of the parallel version: all three spans start together and the timeline compresses.
Distributed trace of the parallel version: all three spans start together and the timeline compresses.

The parallel trace compresses that same work into overlapping spans that start together and finish close together. This is the visual proof that IDbContextFactory actually delivers separate, non blocking database connections rather than just looking parallel on paper.

When this pattern is the wrong call

IDbContextFactory bridges EF Core’s unit of work design with the reality that some endpoints genuinely need to run unrelated queries at the same time. That said, reach for it selectively rather than as a default habit for every method that touches the database.

Connection pool starvation is the main risk. A single incoming HTTP request now occupies three database connections at once instead of one, and if your app handles high concurrency, that adds up fast and can exhaust the pool under load. Watch your connection pool metrics closely after adopting this pattern in a busy endpoint, not just your response time metrics.

Context overhead is the second consideration. If the queries you are parallelizing are already extremely fast, simple primary key lookups for example, the cost of spinning up multiple contexts and scheduling multiple tasks can end up slower than just running them one after another. This pattern earns its keep on endpoints that combine genuinely independent, moderately expensive reads, like aggregation or dashboard style endpoints, not on chains of cheap, dependent lookups.

The practical takeaway is to look at your own slow endpoints and check whether the awaits inside them are lined up sequentially out of habit rather than necessity. If three or four calls do not depend on each other’s results, IDbContextFactory gives you a safe way to run them together without fighting EF Core’s thread safety rules.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading