What is connection pooling?
A connection pool is a cache of already-open database connections held by your application process. When your code opens a connection it does not usually create one - it borrows an idle connection from the pool. When it closes one, the connection is not torn down; it goes back to the pool for the next caller. Physical connect and disconnect happen rarely, and everything in between is nearly free.
In .NET this is on by default. SqlClient pools automatically, which means Entity Framework Core and Dapper pool automatically too. You did not configure it and it is already working, which is exactly why it is worth understanding: the first time you notice the pool is usually the day it runs out.
Open late, close early, and never hold a connection while you are doing something that is not database work.- the whole discipline in one line
That rule has a counter-intuitive consequence worth stating early. Because opening is cheap and returning is what refills the pool, the correct pattern is to open a connection as late as possible and dispose of it as soon as you are done. Caching a SqlConnection in a field to "avoid the cost of opening it" is fighting the pool rather than using it, and it converts a shared resource into a private one.
What opening one really costs
The reason pooling exists is that a genuine connect is expensive, and expensive in a way that does not show up on the database's CPU graph. Establishing a new connection to SQL Server means:
- A TCP handshake with the server.
- A TLS negotiation, since connections are encrypted by default in recent drivers and always in Azure SQL.
- Authentication - and with Microsoft Entra ID that can include acquiring or refreshing a token from the identity provider.
- The SQL Server login sequence: pre-login, login, and setting up the session's context and options.
On a local network that is typically tens of milliseconds. To a cloud database across a region boundary, with TLS and a token acquisition, it can comfortably exceed a hundred. Against that, a well-indexed query taking three milliseconds is a rounding error. Without pooling, most of your request budget would be spent introducing your application to a database it already knows.
Pooling turns that into a lookup. Borrowing an idle connection is a few microseconds of bookkeeping, plus a small reset message on first use. The saving is not marginal - it is the difference between a connection cost that dominates every request and one you can ignore.
How the pool works
Four mechanics explain nearly all pool behaviour.
One pool per connection string, per process. The pool is keyed on the exact connection string text. Change a single character - a different Application Name, a different pooling setting, a different credential - and you get a second, entirely separate pool with its own limit. This is a real production trap: building connection strings dynamically, for example appending a tenant or user identifier, quietly creates one pool per variant and multiplies your connection count by the number of variants.
Min and Max Pool Size. Min Pool Size (default 0) is a floor of connections kept warm even when idle, useful for avoiding a cold-start penalty on the first requests after a quiet period. Max Pool Size (default 100 in SqlClient) is the ceiling. Ask for one past the ceiling and you wait.
Reset on reuse. A pooled connection carries session state - temp tables, SET options, the current database. Before handing it to the next caller, the driver issues sp_reset_connection to clear that state, so one request cannot see another's leftovers. The practical implication: never rely on session state surviving between requests, because it will not, and reaching for it means you have a bug that happens to work when the pool hands you back the same connection.
Idle cleanup. Connections idle for several minutes are closed and removed, down to Min Pool Size. The pool shrinks back after a traffic spike rather than holding server resources indefinitely.
Not the server's limit
This is the distinction that makes capacity planning make sense, and it is regularly missed.
Max Pool Size is a client-side limit, and it applies per process. It is not a setting on the database and the database does not know about it. Run three instances of your API and each one has its own pool of up to 100, so the server can see up to 300 sessions from your application alone. Add a background worker and a couple of functions and the arithmetic gets away from you quickly.
Meanwhile the server has its own limits. Azure SQL Database caps concurrent sessions and concurrent workers by service tier, and those caps are considerably lower on small tiers than people assume. When you exceed them you get a different error from a different layer - a rejected login rather than a pool timeout - and the fix is different too.
Serverless and elastically-scaled hosting is where this bites hardest. A platform that scales to fifty instances under load, each with its own pool, can exhaust a database's session limit while every individual instance believes it is behaving impeccably. If your application scales horizontally, size the pool with the instance count in mind: the number that matters to the server is max pool size multiplied by peak instance count.
Pool exhaustion
When every connection in the pool is checked out and another request asks for one, it waits. If nothing is returned within Connect Timeout (15 seconds by default), you get the message from the cover:
Read that message carefully, because it is more precise than it looks. It says nothing about the database being slow, busy, or unreachable. It says your process could not get a connection from its own pool. The database is frequently idle at the exact moment this fires, which is why "we scaled up the database and it did not help" is such a common follow-up.
The real causes, roughly in order of how often they turn out to be the answer:
- Leaked connections that are never returned. The next section, and the most common single cause.
- Slow queries holding connections for the duration. A query that takes two seconds occupies a connection for two seconds; a hundred concurrent ones occupy the whole pool. The fix is in the query plan, not the pool size.
- Long transactions. A connection inside an open transaction cannot be returned, so everything in the transaction article about keeping them short applies here with double force.
- External calls made while a connection is open. An HTTP request to a payment provider, made with a connection checked out, ties up a pooled resource for the entire round trip - including its timeout, on the bad day.
- Thread pool starvation from sync-over-async. Blocking on
.Resultor.Wait()consumes threads; as the thread pool starves, requests take longer to complete, so connections are held longer, so the pool drains. The two failures amplify each other and the symptom shows up here first. - Genuine concurrency beyond what the pool is sized for. The least common of the six, though always the first one suspected.
Leaks
A leaked connection is one that was opened and never returned. It is not returned by garbage collection in any timely way - the finalizer may run much later, or effectively never while the process is under memory pressure it is handling comfortably. Every leaked connection permanently reduces your pool by one, so the application works fine after a restart and dies four hours later. That shape - healthy on deploy, exhausted by mid-afternoon, fine again after a recycle - is close to diagnostic.
With an ORM the same rule applies one level up. A DbContext owns a connection while it is in use, so its lifetime is the thing to get right: register it as scoped, one per request, and let the container dispose it. A singleton DbContext is three bugs at once - a connection held forever, an identity map that grows without bound, and a type that is explicitly not thread-safe being shared across concurrent requests.
One naming collision worth clearing up: EF Core's AddDbContextPool pools context objects, reusing the instances themselves to avoid the cost of rebuilding their internal state. That is a different mechanism from connection pooling, which happens below it in the driver. Using one does not replace the other, and neither one rescues a context whose lifetime is wrong.
Sizing and settings
The instinct on seeing a pool timeout is to raise Max Pool Size. It is almost always the wrong first move: it buys a little time, hides the leak or the slow query underneath, and pushes the failure toward the server's own session limit, where the error message is less helpful.
A better way to think about the number is Little's law. The connections you need at steady state are roughly throughput × average time a connection is held. At 200 requests per second holding a connection for 10 ms each, that is about 2 concurrent connections. If you believe you need 200, then either each request is holding a connection for a full second - in which case fix that - or connections are not being returned at all. The default of 100 is generous for most applications, and needing far more is nearly always evidence rather than a requirement.
The settings worth knowing, and one pair that is chronically confused:
Connect Timeout(default 15 s) - how long to wait to get a connection, including waiting for the pool. This is the one in the exhaustion message.- Command timeout (default 30 s) - how long to wait for a query to finish. A completely different clock. Raising one when you meant the other is a common and confusing waste of an afternoon.
Min Pool Size- keep a few connections warm if you have bursty traffic after idle periods.Max Pool Size- remember to multiply by instance count before comparing it to the server's session limit.- Retry on transient failures. Cloud databases fail over, get throttled, and move; connections drop as a normal event, not an exceptional one. EF Core's
EnableRetryOnFailureand SqlClient's built-in connection retry handle the common cases, and anything talking to Azure SQL should have one of them enabled.
Practical rules
usingorawait usingon every connection. No exceptions, no clever lifetimes, no connection stored in a field.- Scope your
DbContextper request. Never singleton, never shared across threads. - Open late, close early. Do the computation, the mapping, and the external calls outside the window in which you hold a connection.
- Never make a network call with a connection checked out. The same rule as transactions, for the same reason: you are holding a shared resource while waiting on something you do not control.
- Keep the connection string byte-identical across your application, or accept that you have as many pools as you have variants.
- Treat a pool timeout as a symptom, not a sizing problem. Look for the leak, the slow query, or the long transaction first. Raise the ceiling only after you know why the floor was reached.
- Count instances when planning capacity. Max pool size times peak replica count is the number the server actually sees.
- Enable transient retry against any cloud database.
Connection pooling is one of the few pieces of infrastructure that is genuinely well-designed enough to ignore - right up until the moment it is the loudest thing in your incident channel. The good news is that its failure mode is honest: the error names the pool, not the database. Once you know to read it that way, the investigation goes to the right place, which is nearly always your own code holding something it should have handed back.