Fixing Timeout Expired Pool Exhaustion in Entity Framework Core

This guide is part of .NET & ADO.NET Connection Pooling. Timeout expired from an EF Core application has three distinct causes that produce an identical exception, and applying the wrong remedy is the usual reason the problem recurs. This page separates them and gives the fix for each.

Microsoft.Data.SqlClient.SqlException (0x80131904): Timeout expired.
The timeout period elapsed prior to obtaining a connection from the pool.
This may have occurred because all pooled connections were in use and
max pool size was reached.
   at Microsoft.Data.ProviderBase.DbConnectionFactory.TryGetConnection(...)
   at Microsoft.EntityFrameworkCore.Storage.RelationalConnection.OpenDbConnectionAsync(...)

The stack tells you the pool refused, and nothing more. Whether that is because a DbContext was never disposed, because a transaction spanned an HTTP call, or because the ceiling is genuinely too low is what the next few minutes of investigation must establish.

Rapid Incident Diagnosis

Run these three in order; each eliminates one cause.

1. Is the connection count growing monotonically? Query the server:

SELECT status, count(*) AS sessions,
       max(datediff(second, last_request_end_time, getdate())) AS max_idle_seconds
FROM sys.dm_exec_sessions
WHERE is_user_process = 1 AND program_name = 'orders-api'
GROUP BY status;

Many sleeping sessions with a large max_idle_seconds means connections are checked out and doing nothing — a leaked DbContext or a transaction held open. Sessions predominantly running means the pool is genuinely busy.

2. Does a restart clear it, and does it return? A monotonic climb reset by a restart is the definitive leak signature. Load-driven exhaustion returns immediately after a restart under the same load; a leak takes as long to return as it took the first time.

3. Does the failure correlate with a specific endpoint? EF Core’s DbContext is registered per request by default, so a leak usually traces to a code path that resolves a context outside the request scope — a background task, a singleton service, or a manually constructed context.

Three causes, three signatures A leaked context climbs monotonically and is cleared by a restart. A long-held transaction produces sleeping sessions that correlate with a slow dependency. Genuine exhaustion produces running sessions that track load. leaked DbContext sessions climb and never fall restart clears it completely → find the context resolved outside the request scope sleeping, long idle time transaction spans a call sessions rise with dependency latency restart does not help for long → split the transaction around the external call sleeping, with an open transaction genuine exhaustion sessions track load and fall with it restart changes nothing → raise the ceiling within the allowance, or reduce hold time running, short idle time Session status plus idle time plus the restart test separates all three in under five minutes.
Three causes with one exception between them. Session status and the restart test are what distinguish them, and each needs a different fix.

Cause 1 — A Context Outside the Request Scope

AddDbContext registers DbContext with a scoped lifetime, which the ASP.NET Core request pipeline disposes at the end of each request. That is correct and leak-free. Three patterns escape it.

Injecting a scoped context into a singleton. The container resolves it once and the singleton holds it forever, along with its connection. ASP.NET Core detects this at start-up in development and throws, but the check can be disabled and does not cover manual resolution.

Resolving from the root provider in a background task. IServiceProvider.GetRequiredService<OrdersContext>() on the root provider creates a context with no scope to dispose it. The fix is to create an explicit scope:

// Correct: an explicit scope the background task owns and disposes.
public class OrderReconciler(IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            using var scope = scopeFactory.CreateScope();          // owns the lifetime
            var db = scope.ServiceProvider.GetRequiredService<OrdersContext>();
            await ReconcileAsync(db, ct);
            // scope disposed here — context and connection released
            await Task.Delay(TimeSpan.FromMinutes(1), ct);
        }
    }
}

Constructing a context manually without disposing it. new OrdersContext(options) outside a using holds its connection until finalisation. Use IDbContextFactory<T> where a manually created context is genuinely needed, and dispose it explicitly.

Cause 2 — A Transaction Spanning Non-Database Work

Without an explicit transaction, EF Core opens and closes a connection per operation, so hold time is close to query time. With one, the connection is held for the whole transaction — including anything else inside it.

// Before: the connection is held for the payment gateway's full latency.
await using var tx = await db.Database.BeginTransactionAsync();
var order = await db.Orders.FindAsync(id);
var result = await paymentGateway.ChargeAsync(order.Total);   // 800 ms, no database work
order.PaymentRef = result.Reference;
await db.SaveChangesAsync();
await tx.CommitAsync();

// After: two short transactions around the external call.
Order order;
await using (var tx1 = await db.Database.BeginTransactionAsync())
{
    order = await db.Orders.FindAsync(id);
    order.Status = OrderStatus.Charging;
    await db.SaveChangesAsync();
    await tx1.CommitAsync();
}

var result = await paymentGateway.ChargeAsync(order.Total);   // no connection held

await using (var tx2 = await db.Database.BeginTransactionAsync())
{
    order.PaymentRef = result.Reference;
    order.Status = OrderStatus.Paid;
    await db.SaveChangesAsync();
    await tx2.CommitAsync();
}

The trade is explicit: atomicity across the gateway call is given up, so the write path needs an idempotency key and a reconciliation step for the case where the process dies between the two transactions. That is more work than leaving it as one transaction, and it is the only version that scales — the first holds a connection for 800 ms per order, which at any meaningful order rate exhausts any pool.

TransactionScope deserves a specific warning here. An ambient scope spanning two different connections promotes to a distributed transaction, which pins both for the scope’s duration and multiplies the hold time further. Enlist=false in the connection string prevents the automatic enlistment on services that never intend it.

Cause 3 — Genuine Exhaustion

If sessions are running and track load, the pool is simply too small for the work. The remedies, in order of preference:

Reduce hold time. Eliminate N+1 patterns with Include or explicit projection, move serialisation outside the transaction, and check for AsNoTracking on read paths — change tracking on large result sets extends the window materially.

Raise the ceiling, within the allowance. Only after confirming that the derived value from Tuning Max Pool Size in SqlClient Connection Strings leaves room.

Shorten Connect Timeout. This does not fix exhaustion but changes what it costs: failing in 5 seconds instead of 15 releases request threads three times faster and keeps the queue short.

Hold time before and after splitting the transaction A single transaction spanning a payment gateway call holds a connection for the gateway's full latency. Two short transactions around the call reduce hold time by an order of magnitude. one transaction — 860 ms held connection held across the gateway call it never needed load payment gateway — 800 ms save two transactions — 60 ms held txn 1 no connection held — the pool is free during the slowest part of the request txn 2 A 14× reduction in required pool size, from a change that touches no configuration The cost is an idempotency key and a reconciliation path for the window between the two transactions
Splitting the transaction around the external call reduces connection demand by more than any configuration change could, at the cost of an explicit atomicity trade.

Validation and Verification

For a leak fix, run the service under steady load for an hour and confirm the session count is flat rather than climbing. The floor of the series is the diagnostic: load returns to baseline, a leak does not.

For a transaction-scope fix, compare max_idle_seconds on sleeping sessions before and after. A long-held transaction shows as sessions sleeping with an open transaction; after the split, sessions should be sleeping only for the pool’s idle interval.

-- Sessions holding an open transaction with nothing running.
SELECT s.session_id, s.status, s.program_name,
       datediff(second, s.last_request_end_time, getdate()) AS idle_seconds,
       t.transaction_id
FROM sys.dm_exec_sessions s
JOIN sys.dm_tran_session_transactions t ON t.session_id = s.session_id
WHERE s.is_user_process = 1 AND s.status = 'sleeping'
ORDER BY idle_seconds DESC;

An empty result after the change is the confirmation. Rows with large idle_seconds mean a transaction is still spanning work it should not.

For a sizing fix, confirm that Timeout expired exceptions stop while session count stays within the derived allowance. Both conditions matter — the exceptions stopping because the pool grew past its allowance moves the problem to the database.

Where the context lifetime is owned In a request the ASP.NET Core pipeline creates and disposes the scope. In a background task nothing does, so the scope must be created explicitly or the context and its connection are never released. inside a request pipeline creates the scope pipeline disposes it at the end no leak possible — the framework owns it in a background task no request, so no scope exists nothing disposes the context create a scope explicitly with IServiceScopeFactory Every leak in this category comes from code that runs outside a request but assumes a request's lifetime management.
The request pipeline owns the context lifetime and never leaks. Anything running outside a request must create and dispose its own scope, and forgetting to is the whole of cause 1.

Frequently Asked Questions

Does AddDbContextPool help with this?
No. It pools DbContext instances to reduce allocation, and has no effect on connection count or on any of the three causes above. The name is the source of a great deal of confusion.
Should EnableRetryOnFailure be enabled?
For transient faults, yes — it handles the connection-level errors a managed database produces during reconfiguration. It does not help with pool exhaustion, and on a saturated pool a retry adds load; keep the retry count low and the classification narrow.
Why does the exception sometimes name RelationalConnection and sometimes not?
The stack varies with where the connection was requested — during query execution, during SaveChanges, or when a transaction begins. The frame is not diagnostic; the message text and the session state are.
Can a single slow query cause this?
Yes, if it is slow enough and frequent enough. A query taking two seconds, called on every request, holds a connection for two seconds per request — a hold time that Little’s Law turns into a large pool requirement. Fixing the query is the fix.
Does AsNoTracking reduce connection hold time?
Indirectly and often materially. Change tracking on a large result set extends the time between the query completing and the connection being released, because the entities must be materialised and tracked before the reader closes. On read-only paths it is close to free to add.
Is IDbContextFactory safer than scoped injection?
For background work, yes — it makes the lifetime explicit and forces a disposal decision at the call site rather than relying on a scope that may not exist. For request handling, scoped injection is simpler and the request pipeline handles disposal correctly, so the factory adds ceremony without benefit.
How long should Connect Timeout be for an EF Core service?
Short enough that a saturated pool releases request threads quickly, and long enough to cover a normal handshake. Three to five seconds covers both for a database in the same region, against a default of fifteen that covers neither well.
Does connection resiliency interact with transactions?
It does, and awkwardly. EnableRetryOnFailure cannot transparently retry a user-initiated transaction, because it cannot know whether the transaction was committed before the failure. EF Core throws in that case and requires the transaction to be wrapped in an execution strategy explicitly — which is a good reason to keep transactions short and few.