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.
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.
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.
Frequently Asked Questions
Does AddDbContextPool help with this?
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?
Why does the exception sometimes name RelationalConnection and sometimes not?
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?
Does AsNoTracking reduce connection hold time?
Is IDbContextFactory safer than scoped injection?
How long should Connect Timeout be for an EF Core service?
Does connection resiliency interact with transactions?
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.Related
- .NET & ADO.NET Connection Pooling — the parent guide on the pooling model.
- Tuning Max Pool Size in SqlClient Connection Strings — deriving the ceiling this page assumes.
- Detecting ORM Connection Leaks in Production — the equivalent procedure on other ORMs.
- PostgreSQL Server-Side Connection Diagnostics — the same distinctions read from PostgreSQL rather than SQL Server.