When an ASP.NET app on IIS slows down, teams often blame the database first. In practice the SQL instance is idle while the worker process sits on exhausted connection pools or waits out a Connect Timeout that is far too short for a busy host. The failure mode looks like flaky timeouts, not a clean error about pool size.
ADO.NET (and the Microsoft.Data.SqlClient stack used by EF Core) pools connections per exact connection string, per process. On IIS that means per app pool worker. If you recycle often, open connections without disposing them, or leave default timeouts in place, you hit the ceiling under normal web traffic long before SQL Server 2022 or 2025 runs out of worker threads.
#How pooling maps to IIS app pools
Each w3wp.exe holds its own pool. A recycle drops that pool and forces new physical logins. Shared and reseller hosts commonly recycle on a schedule or after idle time; that is healthy for memory, but it makes Min Pool Size less useful and makes Connection Lifetime worth setting so half-open sockets do not linger across a recycle window.
Two connection strings that differ only by whitespace, Application Name, or an extra keyword create two pools. Copy-paste drift between Web.config transforms and environment-specific settings is a frequent cause of “we raised Max Pool Size and nothing changed.”
#Connection string knobs that matter
Start from a single canonical string and change one value at a time. For most ASP.NET Core apps on a Windows host, these defaults need an explicit decision rather than hope:
<connectionStrings>
<add name="AppDb"
connectionString="Server=tcp:sql.example.com,1433;
Database=AppDb;
User ID=app_login;
Password=***;
Encrypt=True;
TrustServerCertificate=False;
Max Pool Size=100;
Min Pool Size=0;
Connection Lifetime=300;
Connect Timeout=15;
Application Name=MyApp-Prod;" />
</connectionStrings>
- Max Pool Size: default 100 per string per process. Raise only after you measure wait time on Open(); blind 500+ settings hide leaks and stress SQL logins.
- Min Pool Size: keep 0 on shared IIS hosts where recycles are common; warming a minimum pool helps long-lived VPS workers more than shared app pools.
- Connection Lifetime (seconds): retire pooled physical connections on a schedule so load balancers and failover partners do not hand you a dead socket at 2 AM.
- Connect Timeout: time to establish a physical connection, not to run a query. Fifteen seconds is a sane hosted default; five seconds produces false outages when the SQL host is briefly busy accepting logins.
- Application Name: set it. It shows up in sys.dm_exec_sessions and makes per-app pool abuse obvious on a multi-site SQL instance.
#CommandTimeout is a different lever
Connect Timeout stops at login. CommandTimeout governs each batch. EF Core defaults to 30 seconds on the DbContext; ADO.NET SqlCommand defaults to 30 as well. A report query that needs two minutes should set a higher command timeout locally, not inflate Connect Timeout or Max Pool Size for the whole site.
// EF Core 10 — per-context or per-call, not in the connection string
await using var db = _dbContextFactory.CreateDbContext();
db.Database.SetCommandTimeout(TimeSpan.FromSeconds(120));
var rows = await db.Orders
.Where(o => o.CreatedUtc >= from)
.AsNoTracking()
.ToListAsync(ct);
// Raw command path
await using var cmd = connection.CreateCommand();
cmd.CommandText = "dbo.usp_NightlyReconcile";
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandTimeout = 180; // seconds
If you wrap SQL with Polly or built-in EF transient retries, keep the total budget in mind: three retries with a 30-second command timeout can pin a pool slot for minutes and amplify the outage you meant to soften. Prefer short commands, bounded retries, and a circuit breaker at the HTTP edge so one slow dependency does not fill the pool.
#Spot pool pressure before customers do
On the app side, log SqlException numbers -2 (timeout) and 5 / login failures separately from query timeouts. Timeout after Open() with a healthy SQL CPU graph is pool or network; timeout on ExecuteReader with high CXPACKET or PAGEIOLATCH waits is the engine. On SQL Server, watch connection counts by program_name and login_name:
SELECT s.program_name,
s.login_name,
COUNT(*) AS sessions,
SUM(CASE WHEN r.session_id IS NOT NULL THEN 1 ELSE 0 END) AS active_requests
FROM sys.dm_exec_sessions AS s
LEFT JOIN sys.dm_exec_requests AS r
ON r.session_id = s.session_id
WHERE s.is_user_process = 1
GROUP BY s.program_name, s.login_name
ORDER BY sessions DESC;
Also confirm every data path disposes connections. EF Core scopes usually do the right thing in ASP.NET Core request scope; manual SqlConnection use, static factories, and fire-and-forget background work without await using are still the usual leaks on IIS-hosted apps.
#Practical takeaway
Treat the connection string as part of the app’s capacity plan, not a secret blob you never revisit. One Application Name, an explicit Max Pool Size sized to concurrent requests per worker, Connect Timeout around 15 seconds, CommandTimeout set per operation, and Connection Lifetime so recycles do not inherit dead sockets will remove an entire class of “SQL is down” pages that were really pool exhaustion. Measure Open() wait and sessions by program_name after the next deploy; adjust once from data, not from a generic 500-connection rumor.
Comments
No comments yet