IDisposable — Missing Dispose Exhausts Connection Pools
A background service leaked 100 SqlConnection objects in 30 minutes, crashing the API.
20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.
- IDisposable is the contract for releasing unmanaged resources deterministically.
- The using statement compiles to a try/finally — Dispose() runs even on exception.
- C# 8 using declaration scopes disposal to the end of the enclosing method.
- Never rely on finalizers: they're unpredictable and degrade GC performance.
- HttpClient is the famous exception — don't dispose it per request.
Imagine you rent a hotel room. When you check out, you're supposed to hand the key back to the receptionist so the next guest can use that room. If you just walk out and keep the key, that room is locked to everyone else forever — that's a resource leak. IDisposable is C#'s official 'hand back the key' system. The using statement is the automatic door that makes sure you hand the key back even if you leave in a hurry.
Every C# application talks to the outside world — opening files, connecting to databases, sending data over networks, reading from hardware. These 'outside world' connections are called unmanaged resources, and unlike regular C# objects, the garbage collector has no idea how to clean them up. Leave them open and you've got memory leaks, locked files, and exhausted connection pools running silently in production, eating your server alive. This isn't a theoretical problem — it's one of the top causes of performance degradation in real .NET applications.
IDisposable is the interface C# gives you to solve this exactly. It establishes a contract: 'this object holds onto something expensive, and when you're done with it, call Dispose() so it can release that thing immediately — don't wait for the garbage collector.' The using statement is the syntactic partner that makes following that contract effortless and crash-proof, automatically calling Dispose() even if an exception tears through your code.
By the end of this article you'll understand why IDisposable exists at a deeper level than most developers bother to learn, you'll be able to implement it correctly on your own classes (including the full Dispose pattern that Microsoft uses internally), you'll know how to use the using statement in both its classic and modern C# 8 forms, and you'll spot the three most common mistakes that even experienced devs ship to production.
Why IDisposable Is Not Optional
IDisposable is the interface that defines a deterministic cleanup contract for unmanaged resources — file handles, database connections, sockets, or any OS-level object that the garbage collector cannot reclaim. The single method, Dispose(), is your explicit signal to release those resources now, not whenever the GC decides to run. Without it, your program leaks memory and, more critically, exhausts finite system resources like connection pools.
In practice, the using statement is the safe wrapper: it guarantees Dispose() is called even if an exception is thrown. This is not a style preference — it is a correctness requirement. A SqlConnection that is not disposed stays open in the pool until the GC finalizes it, which can take minutes. During that window, the pool hits its max size (default 100 for SQL Server), and every new OpenAsync() blocks or throws TimeoutException.
Use IDisposable whenever your class holds a resource that implements IDisposable — a file stream, a network client, a database connection, a transaction scope. The rule is simple: if you open it, you dispose it. In production systems, failing to do so is the number one cause of connection pool starvation and socket exhaustion, both of which manifest as intermittent, hard-to-diagnose failures.
Dispose() must be explicit and immediate.Why the Garbage Collector Can't Save You From Resource Leaks
The .NET garbage collector (GC) is brilliant at managing memory — heap objects, strings, arrays, your custom classes. But it only knows about managed memory. It has zero visibility into a file handle your code opened at the OS level, a database connection sitting in SQL Server's connection pool, or a network socket waiting for packets.
Think about what a SqlConnection object actually is. The C# object itself is maybe a few hundred bytes on the managed heap — trivial. But behind it sits an actual TCP connection to your database server, authentication state, potentially a transaction. When the GC eventually collects your SqlConnection object (maybe seconds later, maybe minutes, depending on GC pressure), it reclaims those few hundred bytes of managed memory. But the TCP connection? Gone dark. SQL Server is now holding a half-open connection, waiting for a client that no longer exists.
This is why IDisposable exists: it gives objects a standardised way to clean up their unmanaged resources deterministically — meaning YOU control exactly when that cleanup happens, not the GC's unpredictable schedule. Deterministic cleanup is the keyword here. In time-sensitive systems, waiting for the GC is not an option.
MyClass()) as a last-resort backup. But finalizers run on a dedicated GC thread, at an unpredictable time, and running too many of them degrades GC performance. Never rely on finalizers for timely cleanup — they're a failsafe, not a strategy.The using Statement — Your Automatic Cleanup Guarantee
Writing try/finally blocks around every disposable object gets old fast and clutters your code. The using statement is syntactic sugar that compiles down to exactly that try/finally pattern — meaning it gives you the same iron-clad guarantee with far less noise.
When execution leaves a using block — whether it exits normally, hits a return statement halfway through, or gets blown up by an exception — Dispose() is called. Guaranteed. No exceptions (pun intended).
C# 8 introduced the using declaration — drop the curly braces and the variable lives until the end of the enclosing scope. This is perfect for methods where the resource logically lives for the whole method body. You lose none of the safety and gain cleaner code. Both forms compile to identical IL — it's purely a style choice.
You can also stack multiple using statements without nesting them into a pyramid of doom. Stack them flat, one per line, and C# handles the nesting and disposal order for you (last declared, first disposed — like a stack).
Dispose() — so you won't get a NullReferenceException. This is baked into the compiled try/finally. That said, returning null from a factory that's expected to return an IDisposable is itself a code smell worth fixing.Implementing IDisposable Correctly on Your Own Classes
When YOUR class wraps an unmanaged resource — or holds onto another IDisposable — you need to implement IDisposable yourself. The interface has exactly one method: void Dispose(). Deceptively simple, but the correct implementation has a few important rules.
The full Dispose pattern (sometimes called the 'Dispose(bool disposing)' pattern) handles two scenarios: being called explicitly by your code via Dispose(), and being called by the GC via a finalizer as a last resort. The bool parameter tells you which scenario you're in — if true, you're being called explicitly and can safely touch managed objects. If false, you're inside the GC finalizer and must only touch unmanaged resources (other managed objects may already be collected).
For most application code, you won't write finalizers — that's infrastructure-level code. But you absolutely will write the public Dispose() method and the protected Dispose(bool) helper. Two more non-negotiable rules: make Dispose() safe to call multiple times (idempotent), and once Dispose() has been called, any subsequent method call on that object should throw ObjectDisposedException.
Dispose().Real-World Pattern: Database Connections and the Cost of Getting It Wrong
Nothing illustrates IDisposable stakes more vividly than database connections. SQL Server (and most databases) maintain a fixed-size connection pool — typically 100 connections by default. Your application borrows a connection from this pool, does its work, and returns it. If you forget to Dispose() your SqlConnection, that borrowed connection never returns to the pool.
Under load, a web application that doesn't dispose connections will exhaust the pool in minutes. New requests then queue up waiting for a free connection, timeouts cascade, and your error logs fill with 'Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool.' This is a production emergency that reads like a database outage but is actually a code bug.
The pattern below shows both the problem and the bulletproof solution. Notice we also dispose the SqlCommand — it's IDisposable too, though its consequences are less severe. Dispose everything that implements IDisposable, always. It's a habit, not a case-by-case decision.
IAsyncDisposable: Cleanup in Async Code
Not all cleanup is synchronous. When your resource requires async operations to release — flushing a stream to disk, closing a TCP connection gracefully, or committing a database transaction — the synchronous Dispose() pattern falls short. You'd block the thread or lose data.
IAsyncDisposable was introduced in .NET Core 3.0 / C# 8.0 to solve this. It exposes a single method: ValueTask DisposeAsync(). The using statement has an async variant: await using. The same rules apply — deterministic cleanup, exception safety, and idempotency.
The implementation pattern mirrors the synchronous version: a public DisposeAsync() method calls a protected virtual DisposeAsyncCore() that subclasses can override. The GC.SuppressFinalize call is also expected.
Important: if your class implements both IDisposable and IAsyncDisposable, the DisposeAsync() method should also call Dispose() to cover the synchronous case. But in practice, most classes stick to one or the other. Stick to IAsyncDisposable only if your cleanup genuinely needs async work. If not, use the simpler synchronous pattern.
await using eliminated the blocking, reducing p99 latency by 40%.await using mirrors using exactly, but with async safety.The Try/Finally Escape Hatch — When `using` Won't Compile
The using statement is syntactic sugar for a try/finally block. Your compiler turns using (var db = new into exactly that. But what happens when you need conditional disposal or multiple resources with different lifetimes? The sugar dissolves.DbConnection())
If you're iterating a collection of disposables, or storing a connection in a field that lives beyond a single method, using won't cut it. You need the raw form: declare the variable before the try, dispose in the finally. This ensures cleanup runs even if an exception tears through your logic.
This isn't about being clever. It's about understanding what your language is actually doing. The finally block is your last line of defense. It executes regardless of exception, early return, or thread abort. If you skip it, you're gambling with resource leaks. Production doesn't forgive gamblers.
Dispose() inside the try block. If the dispose itself throws, you'll mask the original exception. Always put it in finally, and consider a separate try/catch around Dispose if you need to log.The Dispose Pattern — Why Your Destructor Probably Lies
The standard Dispose pattern has two entry points: the public Dispose() and a protected Dispose(bool disposing). Most devs implement this by rote, blindly copying the boilerplate from Microsoft docs. That's dangerous.
Dispose(bool disposing) is your single source of truth. The boolean tells you who called it. If disposing is true, release both managed and unmanaged resources. If false, release only unmanaged resources — because the finalizer thread is running, and you can't trust managed objects to still be alive.
That boolean flag exists because of the finalizer. If a consumer forgets to call Dispose(), the finalizer will call Dispose(false). At that point, your class's managed children might already be garbage-collected. Trying to call Dispose() on a managed StreamReader from the finalizer is a race condition waiting to crash your process.
The pattern isn't complexity for its own sake. It's a defensive measure codified over decades of resource-management failures.
if (disposing) branch. If your class doesn't directly hold unmanaged handles (IntPtr, HWND), skip the finalizer entirely. It adds GC pressure for no benefit.The using Directive and Global Modifier
The using directive is your entry point for bringing external namespaces into scope, but it's not just a convenience—it prevents ambiguity and keeps your code maintainable. Without it, you'd write fully qualified names like System.Data.SqlClient.SqlConnection everywhere, which is prone to typos and tedious. The using directive tells the compiler: "When I say SqlConnection, assume this namespace." However, when a project grows, you may add the same using to every file—a violation of the DRY principle. Starting with C# 10, the global modifier solves this: global using System.Data; imports the namespace into every file in the project automatically. Think of it as a project-wide alias. Use global for foundational namespaces like System, System.Linq, or your own utility libraries that every file needs. But beware: overusing global can mask naming collisions and make dependencies harder to trace. Always prefer explicit file-level using directives for project-specific namespaces.
Why this matters: The global modifier reduces boilerplate and ensures consistency, but it can also hide the origin of a type, leading to confusing compilation errors when names clash.
The using Alias and Qualified Alias Member
When two namespaces contain types with the same name—like System.Windows.Forms.TextBox and System.Web.UI.WebControls.TextBox—you face a naming collision that breaks compilation. The using alias gives you a weapon: using WinTextBox = System.Windows.Forms.TextBox; creates a local alias, letting both coexist. But aliases can become unwieldy in large codebases. Enter the qualified alias member: a namespace-level alias paired with :: to disambiguate. For example, using Win = System.Windows.Forms; then Win::TextBox explicitly tells the compiler "start from the alias, not the global namespace." This is crucial when a type in your project shadows a namespace—the :: operator bypasses local types and resolves via the alias. Without it, the compiler might pick a local class named Win and fail. Qualified alias member (global::) can also escape any namespace and always start from the root, a lifesaver when refactoring code that accidentally shadows a system type.
Why this matters: Aliases eliminate collision errors without renaming classes, and qualified alias members ensure deterministic resolution, preventing ambiguous reference bugs at scale.
:: operator to ensure correct resolution.::) to resolve naming conflicts without restructuring your project.The Background Service That Burned Through Connection Pools
SqlConnection inside a loop but forgot to call Dispose(). Each iteration borrowed a connection from the pool but never returned it. Within minutes, all 100 pool slots were leaked.SqlConnection in a using block. Also added a connection pool cleanup metric to detect leaks early — NumberOfFreeConnections in PooledSqlConnectionPool.- Dispose every
IDisposableyou create — no exceptions. A using block is the cheapest insurance you'll ever buy. - Monitor connection pool counters in production. A steadily declining free-connection count means a leak.
- This leak is invisible until you hit the pool limit — by then, the app is already down. Don't wait for the symptom.
using block ended too early or a cached reference was disposed elsewhere.SELECT * FROM sys.dm_exec_sessions (SQL Server) to see how many sessions your app holds. Then add a Counter for NumberOfActiveConnectionPoolGroups in dotnet-counters. If count grows indefinitely, you've got a leak.lsof (Linux) to find the PID holding the handle. Cross-reference with your app's thread pool: a background thread that never released a FileStream is the usual culprit.dotnet-counters monitor --process-id <pid> System.Runtime\[System.Runtime\]\[connection-pool\]dotnet-dump collect --process-id <pid> && dotnet-dump analyze <dump> > dumpobjusing blocks. Set a connection pool limit alert in your monitoring.Key takeaways
Dispose() is how you take that cleanup into your own hands, deterministically.Dispose() is called whether your code exits normally, returns early, or is hit by an exception. It's not just convenience syntax; it's a correctness guarantee.Dispose().Common mistakes to avoid
3 patternsForgetting to dispose SqlConnection/SqlCommand
Calling Dispose() inside a catch block instead of a finally block
Dispose() never runs and the resource leaks. The catch block only executes for caught exceptions; a different exception (e.g. NullReferenceException) bypasses it entirely.Disposing HttpClient after every request
Interview Questions on This Topic
What is the difference between Dispose() and the garbage collector finalizer? When would you implement a finalizer alongside IDisposable, and why does a correct Dispose() implementation call GC.SuppressFinalize(this)?
Dispose() is called explicitly by user code to release unmanaged resources immediately. The finalizer is called by the GC at an indeterminate time as a last resort. You implement a finalizer only when your class holds raw unmanaged resources (like IntPtr handles) that cannot be wrapped in a SafeHandle. GC.SuppressFinalize(this) tells the GC that cleanup has already been done, so it doesn't need to run the finalizer, preventing unnecessary GC pressure.Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.
That's C# Advanced. Mark it forged?
9 min read · try the examples if you haven't