Senior 9 min · March 06, 2026

IDisposable — Missing Dispose Exhausts Connection Pools

A background service leaked 100 SqlConnection objects in 30 minutes, crashing the API.

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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.
✦ Definition~90s read
What is IDisposable and using Statement?

IDisposable is the .NET interface that forces deterministic cleanup of unmanaged resources — things like database connections, file handles, network sockets, and GDI+ objects. The garbage collector handles memory, but it has no idea how to close a SQL Server connection or release a Win32 handle. IDisposable exists to bridge that gap: when you implement it, you promise to free those resources the moment the caller is done, not whenever the GC gets around to it.

Imagine you rent a hotel room.

Without it, every open SqlConnection or FileStream sits in a pool or kernel object table until finalization — which can take seconds or minutes under memory pressure.

In practice, IDisposable is your escape hatch from resource starvation. A missing Dispose() on a SqlConnection doesn't crash immediately — it quietly exhausts the connection pool (default 100 connections per connection string), and your app starts throwing System.InvalidOperationException: Timeout expired under load.

The same pattern kills file handles, HTTP sockets, and database transactions. The using statement (using var conn = new SqlConnection(...)) is the idiomatic C# guarantee: it emits a try/finally that calls Dispose() even if an exception throws.

For async code, IAsyncDisposable and await using do the same for DisposeAsync() — critical in high-throughput services where blocking on cleanup would waste threads.

Alternatives exist but are rare: SafeHandle wraps native handles for finalization-only cleanup, and Span<T> avoids allocation entirely for temporary buffers. But for 99% of resource-bound code — database connections, file I/O, network streams, HttpClient, System.Text.Json serializers — IDisposable is the contract.

If you own a class that holds a Stream, SqlConnection, or CancellationTokenSource, you implement IDisposable (or IAsyncDisposable) and call Dispose() on those fields. The GC will eventually finalize leaked objects, but by then your connection pool is dead, your file is locked, and your production incident is already open.

Plain-English First

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.

The GC Is Not Your Janitor
Finalizers run on an unknown schedule. Relying on them to release database connections guarantees pool exhaustion under load — Dispose() must be explicit and immediate.
Production Insight
A high-throughput API that opens a SqlConnection per request without disposing it will hit the 100-connection pool limit within seconds under load, causing all subsequent requests to timeout with 'System.Data.SqlClient.SqlException: Timeout expired.'
The exact symptom: requests that succeed in isolation fail under concurrency, with no memory pressure or CPU spike.
Rule of thumb: every using block must be scoped to the smallest possible lifetime — open late, dispose early, never share the connection across awaits.
Key Takeaway
1. IDisposable is a contract for deterministic cleanup of unmanaged resources — the GC cannot help you here.
2. The using statement is the only safe pattern; a try/finally without using is error-prone and verbose.
3. Connection pool exhaustion is the most common production failure caused by missing Dispose — it is silent until it breaks.
IDisposable: Missing Dispose Exhausts Connection Pools THECODEFORGE.IO IDisposable: Missing Dispose Exhausts Connection Pools Flow from resource allocation to cleanup with using and Dispose Resource Acquisition Open file, DB connection, or handle using Statement Ensures Dispose on exit or exception Dispose Method Release unmanaged resources immediately Connection Pool Exhaustion Missing Dispose blocks pool reuse IAsyncDisposable Async cleanup with await using Resource Freed Pool restored, no leaks ⚠ Forgetting Dispose leaves connections open until GC finalizes Always wrap in using or try/finally to release promptly THECODEFORGE.IO
thecodeforge.io
IDisposable: Missing Dispose Exhausts Connection Pools
Idisposable Using Csharp

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.

ResourceLeakDemo.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using System;
using System.IO;

namespace TheCodeForge.Demos
{
    class ResourceLeakDemo
    {
        static void Main()
        {
            // ❌ BAD PATTERN — no disposal, resource leak guaranteed
            // The StreamReader holds an OS file handle open.
            // The GC will eventually collect it, but we have no idea when.
            // In a busy app, you can run out of file handles before GC kicks in.
            var leakyReader = new StreamReader("example.txt");
            string firstLine = leakyReader.ReadLine();
            Console.WriteLine($"Read (leaky way): {firstLine}");
            // File handle is still open here. If this method is called in a loop,
            // you'll hit 'Too many open files' IOException.

            // ✅ GOOD PATTERN — explicit Dispose call
            // Dispose() closes the OS file handle immediately, right here.
            var safeReader = new StreamReader("example.txt");
            try
            {
                string safeFirstLine = safeReader.ReadLine();
                Console.WriteLine($"Read (safe way): {safeFirstLine}");
            }
            finally
            {
                // finally block guarantees Dispose runs even if ReadLine() throws.
                safeReader.Dispose();
                Console.WriteLine("File handle released immediately.");
            }
        }
    }
}
Output
Read (leaky way): Hello from example.txt
Read (safe way): Hello from example.txt
File handle released immediately.
Watch Out: GC Finalizers Are Not a Safety Net
Some IDisposable types implement a finalizer (~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.
Production Insight
In one production incident, a service doing batch image processing leaked over 5,000 file handles.
The GC ran, but only every 10 seconds under low memory pressure. By then, the OS limit of 8,192 file handles per process was blown.
Rule: never assume the GC will clean up resource handles fast enough for high-throughput workloads.
Key Takeaway
The GC handles memory, not unmanaged resources.
Deterministic cleanup via IDisposable is the only reliable way to release OS handles, DB connections, and sockets.
Dispose is your tool — not the GC.

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).

UsingStatementPatterns.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
using System;
using System.IO;

namespace TheCodeForge.Demos
{
    class UsingStatementPatterns
    {
        static void Main()
        {
            // ── Pattern 1: Classic using block ──────────────────────────────
            // The compiler wraps this in a hidden try/finally.
            // StreamReader.Dispose() is called the moment we exit the braces.
            using (var classicReader = new StreamReader("example.txt"))
            {
                string content = classicReader.ReadToEnd();
                Console.WriteLine($"Classic using: '{content.Trim()}'\n");
            } // <-- Dispose() called here, file handle released

            // ── Pattern 2: C# 8 using declaration (no curly braces) ─────────
            // The variable lives until the end of this method (Main).
            // Dispose() is called when Main() exits, or if an exception escapes.
            using var modernReader = new StreamReader("example.txt");
            string modernContent = modernReader.ReadToEnd();
            Console.WriteLine($"Modern using: '{modernContent.Trim()}'\n");
            // modernReader.Dispose() will be called automatically at end of Main()

            // ── Pattern 3: Stacking multiple using statements ────────────────
            // No pyramid nesting needed. Disposal order: writer first, then reader.
            // (Last declared = first disposed, just like a stack unwinding)
            using var inputReader  = new StreamReader("example.txt");
            using var outputWriter = new StreamWriter("output.txt");

            string line;
            while ((line = inputReader.ReadLine()) != null)
            {
                outputWriter.WriteLine(line.ToUpper());
            }

            Console.WriteLine("Stacked using: file copied and uppercased.");
            // outputWriter.Dispose() called first (flushes buffer to disk)
            // inputReader.Dispose() called second
            // Both file handles cleanly released
        }
    }
}
Output
Classic using: 'Hello from example.txt'
Modern using: 'Hello from example.txt'
Stacked using: file copied and uppercased.
Pro Tip: using null Is Safe
If the variable inside a using statement is null (e.g. a factory returned null), C# checks for null before calling 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.
Production Insight
A team once replaced all using blocks with manual try/finally to add logging. They forgot one catch — the non-local exit via yield return or async void.
The using block still works because it compiles to try/finally regardless.
Rule: never replace using with hand-written try/finally unless you understand every hidden exit path.
Key Takeaway
Using is not just syntactic sugar — it's a correctness guarantee.
Stop writing manual try/finally for disposal. Use using.
Modern C# makes it shorter without losing safety.

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.

ManagedFileProcessor.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
using System;
using System.IO;

namespace TheCodeForge.Demos
{
    /// <summary>
    /// A class that wraps a StreamWriter (an IDisposable) and exposes
    /// a clean, disposable interface to callers.
    /// This demonstrates the canonical IDisposable implementation pattern.
    /// </summary>
    public class ManagedFileProcessor : IDisposable
    {
        private StreamWriter _writer;       // the inner IDisposable we're wrapping
        private bool _isDisposed = false;  // guards against double-disposal

        public ManagedFileProcessor(string filePath)
        {
            // We own this StreamWriter — we're responsible for disposing it.
            _writer = new StreamWriter(filePath, append: true);
            Console.WriteLine($"ManagedFileProcessor opened: {filePath}");
        }

        public void WriteEntry(string message)
        {
            // Always check before using — throw a helpful exception if disposed.
            // ObjectDisposedException is the standard .NET exception for this case.
            ThrowIfDisposed();

            string timestampedMessage = $"[{DateTime.Now:HH:mm:ss}] {message}";
            _writer.WriteLine(timestampedMessage);
            Console.WriteLine($"Written: {timestampedMessage}");
        }

        // ── Public Dispose: called explicitly by consumers ─────────────────────
        public void Dispose()
        {
            Dispose(disposing: true);

            // Tell the GC not to bother calling the finalizer (if we had one).
            // Since we've already cleaned up, running the finalizer would be wasted work.
            GC.SuppressFinalize(this);
        }

        // ── Protected Dispose(bool): the real cleanup logic ────────────────────
        // 'disposing = true'  → called from our public Dispose(), safe to touch managed objects
        // 'disposing = false' → called from GC finalizer, only touch unmanaged resources
        protected virtual void Dispose(bool disposing)
        {
            if (_isDisposed)
                return; // idempotent — safe to call Dispose() multiple times

            if (disposing)
            {
                // Dispose managed resources — the StreamWriter is managed (it wraps
                // an OS handle, but it knows how to clean that up via its own Dispose).
                _writer?.Dispose();
                Console.WriteLine("ManagedFileProcessor: StreamWriter disposed.");
            }

            // If we had raw unmanaged resources (IntPtr handles, etc.) we'd free them here,
            // regardless of the 'disposing' flag.

            _isDisposed = true;
        }

        private void ThrowIfDisposed()
        {
            if (_isDisposed)
                throw new ObjectDisposedException(
                    nameof(ManagedFileProcessor),
                    "Cannot write to a processor that has already been disposed.");
        }
    }

    class Program
    {
        static void Main()
        {
            // using guarantees Dispose() even if WriteEntry() throws
            using (var processor = new ManagedFileProcessor("app.log"))
            {
                processor.WriteEntry("Application started.");
                processor.WriteEntry("Processing batch job #42.");
            } // Dispose() automatically called here

            Console.WriteLine("--- Processor is now disposed ---");

            // Demonstrate that double-Dispose is safe
            var processor2 = new ManagedFileProcessor("app.log");
            processor2.Dispose();
            processor2.Dispose(); // second call — should not throw or crash
            Console.WriteLine("Double-Dispose completed safely.");
        }
    }
}
Output
ManagedFileProcessor opened: app.log
Written: [14:22:01] Application started.
Written: [14:22:01] Processing batch job #42.
ManagedFileProcessor: StreamWriter disposed.
--- Processor is now disposed ---
ManagedFileProcessor opened: app.log
ManagedFileProcessor: StreamWriter disposed.
Double-Dispose completed safely.
Interview Gold: virtual Dispose(bool) Enables Inheritance
Making Dispose(bool) protected virtual lets subclasses override it to clean up their own resources while still calling base.Dispose(disposing) to ensure the parent cleans up too. This is the exact pattern used throughout the .NET BCL — Stream, DbConnection, HttpClient all follow it.
Production Insight
A developer once forget to call GC.SuppressFinalize. The finalizer ran later, trying to dispose the same StreamWriter already disposed. The StreamWriter's inner safe handle threw ObjectDisposedException on the finalizer thread — crashing the process.
Rule: always call GC.SuppressFinalize in your public Dispose().
If your class has no finalizer, it's still harmless and future-proofs your code if a finalizer is added later.
Key Takeaway
IDisposable implementation requires idempotency, guarded cleanup, and SuppressFinalize.
The bool flag in Dispose(bool) protects you from finalizer corruption.
Make it safe to call Dispose multiple times — your callers will thank you.

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.

DatabaseConnectionDemo.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
using System;
using System.Data;
using System.Data.SqlClient;

namespace TheCodeForge.Demos
{
    class DatabaseConnectionDemo
    {
        // Connection string — in real apps this comes from configuration, not hardcoded.
        private const string ConnectionString =
            "Server=localhost;Database=ShopDb;Integrated Security=true;";

        // ❌ DANGEROUS: Connection is never returned to pool
        static void GetProductNameLeaky(int productId)
        {
            var connection = new SqlConnection(ConnectionString);
            connection.Open(); // borrows a slot from the pool

            var command = new SqlCommand(
                "SELECT ProductName FROM Products WHERE Id = @id", connection);
            command.Parameters.AddWithValue("@id", productId);

            string productName = (string)command.ExecuteScalar();
            Console.WriteLine($"[Leaky] Product: {productName}");

            // No Dispose() — connection stays borrowed from pool forever (until GC, eventually).
            // Under load, call this 100 times and the pool is exhausted.
        }

        // ✅ SAFE: using guarantees both connection and command are returned/released
        static string GetProductNameSafely(int productId)
        {
            // Nested using statements — SqlCommand is disposed before SqlConnection.
            // SqlConnection.Dispose() returns the connection to the pool immediately.
            using var connection = new SqlConnection(ConnectionString);
            connection.Open();

            using var command = new SqlCommand(
                "SELECT ProductName FROM Products WHERE Id = @id", connection);

            // Always use parameterized queries — prevents SQL injection
            command.Parameters.Add("@id", SqlDbType.Int).Value = productId;

            string productName = (string)command.ExecuteScalar();
            Console.WriteLine($"[Safe] Product: {productName}");

            return productName;
            // When this method exits: command.Dispose() first, then connection.Dispose()
            // Connection is immediately available in the pool for the next request.
        }

        // ✅ PATTERN: Wrapping in a service class — consumers don't touch SqlConnection directly
        static void Main()
        {
            // In unit tests or when DB isn't available, wrap in try/catch
            try
            {
                string name = GetProductNameSafely(productId: 1);
                Console.WriteLine($"Final result: {name}");
            }
            catch (SqlException ex)
            {
                Console.WriteLine($"DB error (expected in demo without real DB): {ex.Number}");
            }
        }
    }
}
Output
[Safe] Product: Wireless Keyboard
Final result: Wireless Keyboard
Watch Out: HttpClient Is NOT a Per-Request IDisposable
Unlike SqlConnection, HttpClient should NOT be disposed after each request. Disposing HttpClient closes the underlying TCP socket, causing socket exhaustion under load (the infamous 'port exhaustion' bug). HttpClient is designed to be shared — create one instance per endpoint and reuse it, or use IHttpClientFactory in ASP.NET Core. It's the exception that proves the rule: always read the docs before assuming 'IDisposable = dispose immediately after use'.
Production Insight
A common pattern in microservices is to inject an HttpClient via IHttpClientFactory. But if you dispose the factory's HttpClient instance manually, you break the connection pooling that the factory manages.
The result: socket exhaustion under moderate load.
Rule: never dispose an HttpClient obtained from IHttpClientFactory — the factory handles its lifecycle.
Key Takeaway
SqlConnection leaks are the #1 cause of 'timeout expired' in .NET apps.
Always use using statements for database connections — every single time.
HttpClient is the exception — share it, don't dispose it per request.

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.

AsyncResourceHolder.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
using System;
using System.IO;
using System.Threading.Tasks;

namespace TheCodeForge.AsyncPatterns
{
    public class AsyncResourceHolder : IAsyncDisposable
    {
        private FileStream _fileStream;
        private bool _disposed = false;

        public AsyncResourceHolder(string path)
        {
            _fileStream = new FileStream(path, FileMode.Create);
        }

        public async Task WriteAsync(string content)
        {
            ThrowIfDisposed();
            byte[] buffer = System.Text.Encoding.UTF8.GetBytes(content);
            await _fileStream.WriteAsync(buffer, 0, buffer.Length);
        }

        // ── Public DisposeAsync ─────────────────────────────────────────
        public async ValueTask DisposeAsync()
        {
            await DisposeAsyncCore();

            Dispose(disposing: false);
            GC.SuppressFinalize(this);
        }

        // ── Protected virtual DisposeAsyncCore ──────────────────────────
        // Only dispose managed resources here.
        // Subclasses can override this to add their own async cleanup.
        protected virtual async ValueTask DisposeAsyncCore()
        {
            if (_disposed)
                return;

            if (_fileStream != null)
            {
                // Flush and close async
                await _fileStream.FlushAsync();
                await _fileStream.DisposeAsync();
                _fileStream = null;
            }

            _disposed = true;
        }

        // ── Synchronous Dispose for fallback (if needed) ────────────────
        // Usually not needed if you implement only IAsyncDisposable.
        // But if you also implement IDisposable, call DisposeAsyncCore synchronously?
        // Better to throw NotSupportedException and tell callers to use await using.
        public void Dispose()
        {
            throw new NotSupportedException(
                "Use await using to dispose this resource asynchronously.");
        }

        private void ThrowIfDisposed()
        {
            if (_disposed)
                throw new ObjectDisposedException(nameof(AsyncResourceHolder));
        }
    }

    class AsyncDemo
    {
        static async Task Main()
        {
            await using var holder = new AsyncResourceHolder("log.txt");
            await holder.WriteAsync("Hello from async cleanup!");
            // holder.DisposeAsync() is called at end of scope
        }
    }
}
Output
No output (writes to file).
Pro Tip: await using vs using
Use 'await using' for IAsyncDisposable. The compiler generates a try/finally that awaits DisposeAsync. Mixing with synchronous using will not call DisposeAsync — your cleanup won't run.
Production Insight
A streaming service that wrote large payloads to files used synchronous Dispose in a high-throughput endpoint. The thread pool was blocked during file flush, causing latency spikes.
Switching to IAsyncDisposable with await using eliminated the blocking, reducing p99 latency by 40%.
Rule: if your cleanup could block, use IAsyncDisposable. Your thread pool will thank you.
Key Takeaway
IAsyncDisposable is for resources that need async cleanup — file streams, network connections, transactions.
await using mirrors using exactly, but with async safety.
Don't implement both interfaces unless you need to support both sync and async callers.

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 DbConnection()) into exactly that. But what happens when you need conditional disposal or multiple resources with different lifetimes? The sugar dissolves.

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.

ConnectionPoolManager.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — csharp tutorial

SqlConnection? conn = null;
try
{
    conn = new SqlConnection(connectionString);
    conn.Open();
    var cmd = new SqlCommand("SELECT * FROM Orders", conn);
    // ... execute and process
}
finally
{
    if (conn is not null)
    {
        conn.Dispose();
    }
}
Output
No output — connection is always disposed, even on exception.
Production Trap:
Never call 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.
Key Takeaway
The using statement is just syntax. Know when to unsugar it — long-lived references, conditional paths, and async disposal in older frameworks still need raw try/finally.

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.

DatabaseManager.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// io.thecodeforge — csharp tutorial

public class DatabaseManager : IDisposable
{
    private SqlConnection? _connection;
    private bool _disposed;

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        if (disposing)
        {
            _connection?.Dispose();
        }

        // TODO: free unmanaged resources here

        _disposed = true;
    }

    ~DatabaseManager()
    {
        Dispose(false);
    }
}
Output
No output — finalizer calls Dispose(false), public call calls Dispose(true). Both paths are safe.
Senior Shortcut:
99% of the time, you only need the 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.
Key Takeaway
Dispose(bool disposing) exists because finalizers run on a separate thread where managed references are unreliable. Never touch managed objects in Dispose(false).

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.

GlobalUsingExample.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
// io.thecodeforge — csharp tutorial
global using System;          // Every file in project
using System.Data;             // Only in this file

public class DatabaseReader {
    public DataTable Read() {
        var conn = new SqlConnection();  // From global System.Data
        Console.WriteLine("Connecting...");  // From global System
        return new DataTable();
    }
}
Output
No direct output — compiler resolves namespaces.
Production Trap:
Global using directives can cause silent conflicts if two imported namespaces define the same type. Always qualify ambiguous types with their full namespace.
Key Takeaway
Use global using for foundational namespaces; keep file-level directives for domain-specific imports.

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.

UsingAlias.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
// io.thecodeforge — csharp tutorial
using WinForm = System.Windows.Forms;
using WebForm = System.Web.UI.WebControls;
using Project = MyCompany.Project;

public class FormHandler {
    public void Render() {
        var winBox = new WinForm::TextBox();  // Qualified alias
        var webBox = new WebForm::TextBox();  // Qualified alias
        var local = new Project::Foo();       // No collision
    }
}
Output
No direct output — compiler resolves type names successfully.
Production Trap:
Using the same alias name as an existing type can confuse maintainers. Stick to short, capitalized aliases and use the :: operator to ensure correct resolution.
Key Takeaway
Leverage using aliases and qualified alias member (::) to resolve naming conflicts without restructuring your project.
● Production incidentPOST-MORTEMseverity: high

The Background Service That Burned Through Connection Pools

Symptom
After about 30 minutes of steady traffic, all API calls start throwing 'Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool.' The database server shows no load, CPU is idle. Restarting the app service fixes it temporarily.
Assumption
Everyone assumed it was a database performance problem — maybe slow queries locking tables, or the connection string misconfigured. The ops team spent hours tuning SQL Server.
Root cause
A background service that processed message batches created a new 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.
Fix
Wrapped the SqlConnection in a using block. Also added a connection pool cleanup metric to detect leaks early — NumberOfFreeConnections in PooledSqlConnectionPool.
Key lesson
  • Dispose every IDisposable you 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.
Production debug guideSymptom → Action mapping for the most common resource leak scenarios.3 entries
Symptom · 01
ObjectDisposedException thrown when calling methods on a disposed object.
Fix
Check the call stack: the exception tells you exactly which method was called after disposal. Grep the code for that object's lifecycle — likely a using block ended too early or a cached reference was disposed elsewhere.
Symptom · 02
Connection pool exhaustion (timeout expired) despite low database load.
Fix
Run 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.
Symptom · 03
File lock exceptions — 'The process cannot access the file because it is being used by another process'.
Fix
Use Process Explorer (Windows) or 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.
★ IDisposable Quick Debug Cheat SheetUse this when you suspect a resource leak in production — don't guess, collect data.
Timeouts or slow responses after deployment
Immediate action
Quick check: `netstat -ano | findstr :1433` (SQL Server). Look for many `TIME_WAIT` connections from your app.
Commands
dotnet-counters monitor --process-id <pid> System.Runtime\[System.Runtime\]\[connection-pool\]
dotnet-dump collect --process-id <pid> && dotnet-dump analyze <dump> > dumpobj
Fix now
Restart the app to reclaim leaked resources. Then wrap every database call in using blocks. Set a connection pool limit alert in your monitoring.
ObjectDisposedException in production logs+
Immediate action
Check the stack trace. If it's from a cached service, the service was disposed after first use.
Commands
dotnet-trace collect --process-id <pid> --profile gc-verbose
dotnet-dump analyze <dump> -c '!DumpHeap -type MyService -live'
Fix now
Find where the service is registered (DI container vs manual). If registered as scoped but used as singleton, dispose happens once. Change registration to match lifetime. Then add a ExceptionFilter to log full context.
Comparison: Classic using vs C# 8 using declaration
AspectClassic using { } BlockC# 8 using Declaration
Syntaxusing (var x = new Foo()) { ... }using var x = new Foo();
Scope of variableLimited to the { } block onlyLives until end of enclosing method/scope
When Dispose() is calledAt the closing } braceWhen enclosing scope exits (return, end of method, exception)
Nesting multiple resourcesCan create pyramid indentationStack flat — no extra indentation
C# version requiredC# 1.0+C# 8.0+ (.NET Core 3.0+ / .NET 5+)
Compiled output (IL)try/finally blockIdentical try/finally block — no difference
Best forWhen you need the resource for part of a method onlyWhen the resource logically lives for the whole method

Key takeaways

1
IDisposable exists because the GC only manages memory
it has no idea how to close a file handle, database connection, or network socket. Dispose() is how you take that cleanup into your own hands, deterministically.
2
The using statement compiles to a try/finally block
meaning 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.
3
When implementing IDisposable on your own class, follow the Dispose(bool disposing) pattern
make it idempotent (safe to call twice), throw ObjectDisposedException on subsequent method calls, and call GC.SuppressFinalize(this) in your public Dispose().
4
HttpClient is the famous exception
it's IDisposable but must NOT be disposed per-request. Always read the docs — the rule is 'dispose everything IDisposable', with HttpClient as a documented, well-reasoned exception that will break your app at scale if ignored.
5
For async cleanup, use IAsyncDisposable and await using
blocking the thread pool during disposal is a performance anti-pattern.
6
Monitor connection pools and file handle counts in production. Resource leaks are silent until they hit resource limits
by then, your app is down.

Common mistakes to avoid

3 patterns
×

Forgetting to dispose SqlConnection/SqlCommand

Symptom
Under moderate load, you get 'Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool' even though the database itself is fine. The pool is exhausted because connections are never returned.
Fix
Always wrap SqlConnection and SqlCommand in using statements. Make it a non-negotiable team coding standard, enforced via a Roslyn analyzer (CA2000: Dispose objects before losing scope). Use a connection pool health check to catch leaks early.
×

Calling Dispose() inside a catch block instead of a finally block

Symptom
If the code between object creation and the catch block throws, Dispose() never runs and the resource leaks. The catch block only executes for caught exceptions; a different exception (e.g. NullReferenceException) bypasses it entirely.
Fix
Use a using statement — it compiles to try/finally, which runs regardless of whether an exception was thrown or caught. This is exactly the guarantee try/catch alone cannot provide. If you must use manual try/catch, put Dispose in the finally block, not catch.
×

Disposing HttpClient after every request

Symptom
Under load, the application throws SocketException or connections silently fail; netstat shows thousands of sockets in TIME_WAIT state. Port exhaustion occurs because each new HttpClient opens a new TCP connection that stays in TIME_WAIT after disposal.
Fix
Treat HttpClient as a long-lived shared instance. Inject a singleton via IHttpClientFactory in ASP.NET Core, or store a static readonly HttpClient instance. This is a documented Microsoft recommendation that trips up even experienced developers unfamiliar with socket lifecycle.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between Dispose() and the garbage collector final...
Q02SENIOR
If a class inherits from another class that implements IDisposable, how ...
Q03SENIOR
Can you name an IDisposable type in .NET that you should NOT dispose aft...
Q01 of 03SENIOR

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)?

ANSWER
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.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What happens if I don't call Dispose() on an IDisposable object in C#?
02
Is it safe to call Dispose() more than once on the same object?
03
What is the difference between using (var x = ...) and using var x = ... in C#?
04
When should I use IAsyncDisposable instead of IDisposable?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's C# Advanced. Mark it forged?

9 min read · try the examples if you haven't

Previous
Channel in C# for Concurrency
11 / 15 · C# Advanced
Next
Middleware Pipeline in .NET