The garbage collector manages memory — and nothing else. File handles, sockets, database connections, and native buffers don't free themselves when objects become unreachable; they free when something calls Dispose. This post walks .NET's resource-cleanup machinery on .NET 10 with a runnable demo: IDisposable and using, the full dispose pattern, IAsyncDisposable, finalizers as a safety net, and the modern advice that makes most of the ceremony unnecessary.

Deterministic cleanup: IDisposable and using
using (var writer = new ReportWriter("report.tmp"))
{
writer.WriteLine("hello");
} // Dispose runs HERE — exception or not
using compiles to try/finally; disposal is deterministic and exception-safe. The declaration form (using var writer = ...;) disposes at end of scope and removes a nesting level — prefer it except when you want the resource's lifetime visibly shorter than the method.
Verified from the demo run:
-- deterministic cleanup with using --
Dispose(disposing: True)
The dispose pattern, explained line by line
The canonical pattern exists for one scenario: a class that owns an unmanaged resource (or is designed for inheritance while owning resources):
public class ReportWriter : IDisposable
{
private readonly StreamWriter _writer; // managed resource
private IntPtr _fakeNativeHandle = new(42); // stand-in for an unmanaged one
private bool _disposed;
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // finalizer no longer needed
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
_writer.Dispose(); // managed: only on the explicit path
}
_fakeNativeHandle = IntPtr.Zero; // unmanaged: on both paths
_disposed = true;
}
~ReportWriter() => Dispose(disposing: false); // safety net, GC thread
}
Why the disposing flag: when the finalizer calls Dispose(false), other managed objects (like _writer) may already be finalized — touching them is undefined behavior territory. So managed resources are released only on the explicit path (disposing: true), unmanaged ones on both. GC.SuppressFinalize tells the GC not to run the finalizer for properly disposed objects — finalizable objects survive an extra GC generation, so suppressing is a real performance courtesy.
The demo proves the safety net fires when someone forgets:
-- forgotten Dispose: the finalizer safety net fires on GC --
Dispose(disposing: False)
What you should actually write in 2026
The full pattern above is mostly a reading skill, not a writing skill:
- Own only managed disposables? Implement
Dispose()that disposes them, done. No finalizer, noDispose(bool), no virtual anything for sealed classes. - Own a real OS handle? Wrap it in a
SafeHandlesubclass instead of anIntPtr. SafeHandle has its own rock-solid critical finalizer; your class goes back to the simple case and you delete your finalizer. - Writing a finalizer yourself is justified roughly never in application code — they run on a dedicated thread at an unpredictable time, resurrect object graphs for a generation, and turn cleanup bugs into heisenbugs.
ObjectDisposedException.ThrowIf(_disposed, this)in public members completes the contract.
Async disposal
Flushing a network stream or closing a database connection may itself be I/O. IAsyncDisposable lets cleanup await:
public class ResourceStream : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
await Task.Delay(10); // e.g. FlushAsync over the network
Console.WriteLine(" DisposeAsync ran");
}
}
await using (var stream = new ResourceStream())
{
await stream.WorkAsync();
}
-- async disposal --
working...
DisposeAsync ran
await using is the async twin of using. Types like DbContext and Stream implement both; prefer await using in async methods so a slow flush doesn't block a thread. If you implement both interfaces, the guidance is DisposeAsync does the real work and Dispose remains a synchronous fallback.
The rules that prevent real bugs
- Whoever creates it disposes it. Factories that return
IDisposabletransfer ownership to the caller; document it. - Injected dependencies are not yours to dispose — the DI container owns their lifetime. Disposing an injected
HttpClientorDbContextbreaks the next consumer; this is the most common dispose bug in ASP.NET Core code, and DI lifetimes are the other half of that story. - Fields that are disposables make your class disposable too — propagate the interface upward until a
usingat some scope terminates the chain. Disposemust be idempotent (the_disposedcheck) — callers may double-dispose, and the contract says that's fine.
The GC will eventually reclaim memory whether you do any of this or not. What it won't do is return your file locks, sockets, and connection-pool slots on time — that part is yours, and it's three keywords: using, await using, and (almost never) ~. The complete runnable demo is in the companion repository.
Comments (0)
No comments yet — be the first to share your thoughts.