Skip to content

Disposable & Resource Management

Key Points

  • IDisposable is the synchronous resource-cleanup contract. Implement it on any class holding an unmanaged resource (file handle, DB connection, lock, native buffer).
  • IAsyncDisposable (.NET Core 3+) is the async equivalent — use when cleanup itself is async (network drain, async flush).
  • using declaration (using var x = ...;) is concise and widely preferred over using (...) { } blocks.
  • await using is for IAsyncDisposable. ASP.NET Core's AsyncServiceScope uses it.
  • Don't write a finalizer unless you really need a backstop. Use SafeHandle for native handles.
  • GC.SuppressFinalize(this) is required in your Dispose() only if you have a finalizer — to skip the gen2 promotion penalty.
  • The dispose pattern in modern code is simpler than the legacy template — just implement Dispose() and (optionally) DisposeAsync().

Concepts (deep dive)

using and using declarations

// Block form (legacy):
using (var f = File.OpenRead(path))
{
    Process(f);
}

// Declaration form (C# 8+; preferred):
using var f = File.OpenRead(path);
Process(f);
// f.Dispose() called at end of enclosing scope

The declaration form is concise and avoids deeply nested blocks when you have multiple disposables. The compiler lowers both to try/finally with Dispose.

💡 Senior insight: in a method with many disposables, the declaration form keeps logical flow at the top of indentation; the block form forces a step-down per resource.

await using

public async Task ProcessAsync()
{
    await using var scope = _provider.CreateAsyncScope();
    var svc = scope.ServiceProvider.GetRequiredService<IFoo>();
    await svc.DoAsync();
    // scope.DisposeAsync() awaited here
}

Use await using for IAsyncDisposable. Critical when the dispose itself does async work (DB connection drain, network flush). await using is using + await Dispose Async().

IDisposable shape

public sealed class CounterScope : IDisposable
{
    private readonly Action _onDispose;
    private bool _disposed;

    public CounterScope(Action onDispose) => _onDispose = onDispose;

    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;
        _onDispose();
        // No finalizer here, so no SuppressFinalize call needed.
    }
}

Modern shape: - Dispose() is idempotent (calling twice is fine). - No finalizer unless you need one (with SafeHandle you don't). - No GC.SuppressFinalize unless you have a finalizer. - Mark sealed if not designed for inheritance.

IAsyncDisposable shape

public sealed class StreamWriterAsync : IAsyncDisposable
{
    private readonly Stream _inner;
    private bool _disposed;

    public StreamWriterAsync(Stream inner) => _inner = inner;

    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        _disposed = true;
        await _inner.FlushAsync().ConfigureAwait(false);
        await _inner.DisposeAsync().ConfigureAwait(false);
    }
}

DisposeAsync returns ValueTask (zero-allocation if completes synchronously). When the type holds only async-disposable resources, IAsyncDisposable alone is fine. If it holds both sync and async, implement both:

public sealed class Both : IDisposable, IAsyncDisposable
{
    public void Dispose() { /* sync cleanup */ }

    public async ValueTask DisposeAsync()
    {
        await /* async cleanup */;
        Dispose();   // call sync as the last step
    }
}

When to write a finalizer

~MyType() => /* release */;

Almost never. A finalizer: - Forces gen2 promotion (the object survives at least one collection). - Runs on a single low-priority thread. - Runs in unspecified order. - Throwing from it crashes the process in modern .NET.

If you wrap an unmanaged handle, use SafeHandle — see GC Tuning & Diagnosing:

public sealed class FileWrapper : IDisposable
{
    private readonly FileSafeHandle _handle;
    public FileWrapper(string path) { _handle = NativeApi.Open(path); }
    public void Dispose() => _handle.Dispose();
}

SafeHandle is IDisposable and the runtime guarantees ReleaseHandle() runs even during AppDomain unload or process tear-down. No finalizer needed in your domain types.

The legacy "Dispose pattern"

You'll see this in old code:

public class LegacyType : IDisposable
{
    private bool _disposed;
    private IntPtr _native;          // unmanaged
    private Stream _inner;            // managed

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

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;
        if (disposing)
        {
            _inner?.Dispose();
        }
        if (_native != IntPtr.Zero) NativeApi.Close(_native);
        _native = IntPtr.Zero;
        _disposed = true;
    }

    ~LegacyType() => Dispose(disposing: false);
}

Modern alternative: SafeHandle for native; just IDisposable for managed. No finalizer, no Dispose(bool).

using and exceptions

using var f = File.OpenRead(path);
ThrowSometimes();
// f.Dispose() runs even if ThrowSometimes throws — guaranteed by try/finally.

The dispose runs in the implicit finally. If Dispose itself throws AND the body threw, the body's exception is lost and the dispose's wins. (You should very rarely throw from Dispose.)

Multiple disposables — order matters

using var conn = new SqlConnection(...);
using var cmd = conn.CreateCommand();
// cmd disposed first (LIFO of declarations), then conn

The declaration order determines disposal order. Inner-most-declared disposes first — same as using blocks nested.

Resource ownership

public class Wrapper(Stream inner)
{
    private readonly Stream _inner = inner;
    // Does Wrapper "own" inner? Should Wrapper.Dispose() call _inner.Dispose()?
}

Decide explicitly:

  • Owned: Wrapper.Dispose() calls _inner.Dispose(). Caller passes ownership at construction; caller doesn't dispose.
  • Borrowed: caller still owns; Wrapper does not dispose. Convention: pass an IDisposable.Wrap(...) if you want to opt-in to ownership.

Document this in the constructor's XML comment. Confusion here causes "object disposed" exceptions.

IAsyncEnumerable + await using

public async IAsyncEnumerable<int> ReadAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await using var conn = await OpenAsync(ct);   // disposed when iterator exits
    while (true)
    {
        var x = await conn.NextAsync(ct);
        if (x is null) yield break;
        yield return x.Value;
    }
}

The await using is captured in the iterator's state machine and runs when the consumer exhausts or stops the enumeration.

Disposable scopes pattern

public static class Telemetry
{
    public static IDisposable BeginScope(string name)
        => new ScopeImpl(name);

    private sealed class ScopeImpl : IDisposable
    {
        private readonly string _name;
        private readonly Stopwatch _sw = Stopwatch.StartNew();
        public ScopeImpl(string name) { _name = name; }
        public void Dispose() => Logger.LogInformation("{Name} took {Ms}ms", _name, _sw.ElapsedMilliseconds);
    }
}

// Use:
using (Telemetry.BeginScope("DB.Query"))
{
    /* ... */
}

The "disposable scope" idiom is everywhere: Activity for tracing, Logger.BeginScope, transactions, locks. It's a powerful way to attach exit behavior to a code block.

IDisposable and DI

ASP.NET Core's DI container disposes scoped + transient IDisposables when the scope ends. Don't dispose them yourself — the container will:

public class Service(DbContext db) // DbContext is IDisposable; injected as scoped
{
    public void Use() { /* don't call db.Dispose() */ }
}
// Container disposes db when the request scope ends.

Captive-dependency warning: a singleton holding a transient (or scoped) IDisposable keeps it alive forever — the singleton holds a reference. Container can't dispose. Symptoms: connections never released, file handles leaked. See Dependency Injection.


How it works under the hood

using lowers to:

{
    var f = File.OpenRead(path);
    try { /* body */ }
    finally { f?.Dispose(); }
}

await using lowers to:

{
    var f = await OpenAsync();
    try { /* body */ }
    finally { if (f is not null) await f.DisposeAsync(); }
}

The compiler handles IDisposable via the standard Dispose() call. For IAsyncDisposable, it calls DisposeAsync().

SafeHandle interop with Dispose: SafeHandle.Dispose() decrements an internal reference count; only when the count reaches zero is ReleaseHandle() called. P/Invoke marshalers cooperate, so a SafeHandle argument can't be released mid-call by another thread.

DI containers track IDisposable registrations during scope construction. At scope dispose, they walk the list LIFO and Dispose() each.


Code: correct vs wrong

❌ Wrong: forgetting to dispose

public string ReadFirstLine(string path)
{
    var s = File.OpenRead(path);   // leak
    return new StreamReader(s).ReadLine();
}

✅ Correct: using

public string ReadFirstLine(string path)
{
    using var s = File.OpenRead(path);
    using var r = new StreamReader(s);
    return r.ReadLine() ?? "";
}

❌ Wrong: not awaiting DisposeAsync

public async Task DoAsync()
{
    var conn = await OpenAsync();
    try { await conn.UseAsync(); }
    finally { _ = conn.DisposeAsync(); }   // ❌ fire-and-forget
}

✅ Correct: await using

public async Task DoAsync()
{
    await using var conn = await OpenAsync();
    await conn.UseAsync();
}

❌ Wrong: writing a finalizer for managed cleanup

public class Cache : IDisposable
{
    public void Dispose() => Clear();
    ~Cache() => Clear();   // ❌ runs on finalizer thread; gen2 promotion; serializes
}

✅ Correct: just IDisposable

public class Cache : IDisposable
{
    public void Dispose() => Clear();
}

❌ Wrong: catching exception in Dispose and silently dropping

public void Dispose()
{
    try { _conn.Close(); } catch { }   // hides real failures
}

✅ Correct: log and continue

public void Dispose()
{
    try { _conn.Close(); }
    catch (Exception ex) { _log.LogWarning(ex, "close failed"); }
}

(Or: just let it throw. The caller may want to know.)


Design patterns for this topic

Pattern 1 — "using var everywhere"

  • Intent: concise, idiomatic resource cleanup.
  • When to use it: every disposable in a method.

Pattern 2 — "await using for async resources"

  • Intent: correct async cleanup.

Pattern 3 — "Disposable scope for telemetry/timing"

  • Intent: attach exit behavior to a code block.
  • Code sketch: see Telemetry above.

Pattern 4 — "SafeHandle over finalizer"

  • Intent: safer native resource ownership.

Pattern 5 — "Document ownership"

  • Intent: clarify who disposes.
  • Code sketch:
/// <param name="ownStream">If true, this object disposes <paramref name="stream"/>.</param>
public Wrapper(Stream stream, bool ownStream) { ... }

Pros & cons / trade-offs

Approach Pros Cons
using declaration Concise; no nesting Ties dispose to method scope
using block Explicit scope control More indentation
await using Async-correct Newer (.NET Core 3+)
IDisposable only Simple No async cleanup
Finalizer Backstop Gen2 promotion; rarely worth it
SafeHandle Runtime-guaranteed release Slight overhead; one per native

When to use / when to avoid

  • Use using declarations for almost all disposables.
  • Use await using for IAsyncDisposable.
  • Use SafeHandle for native resources.
  • Avoid finalizers unless you have a real reason.
  • Avoid disposing twice — make Dispose idempotent.
  • Avoid disposing borrowed resources — document ownership.

Interview Q&A

Q1. What's the difference between using block and using declaration? The block (using (...) { ... }) creates an explicit scope; dispose at end of block. The declaration (using var x = ...;) ties dispose to the enclosing method/scope. Both lower to try/finally.

Q2. When should you implement IAsyncDisposable? When cleanup itself is async — flushing buffers, draining connections, awaiting graceful shutdown. Just IDisposable if cleanup is sync.

Q3. Should you implement both IDisposable and IAsyncDisposable? Yes, if your type holds both sync and async resources. The async one runs first (cleaner async cleanup); call sync Dispose() at the end of DisposeAsync for the sync remnants.

Q4. Why is GC.SuppressFinalize needed in dispose pattern? Only when you have a finalizer. It removes the object from the finalization queue, avoiding gen2 promotion. If your class has no finalizer, SuppressFinalize is a no-op.

Q5. Why is SafeHandle better than a finalizer? The runtime treats SafeHandle specially: it reference-counts across P/Invoke calls, releases even during AppDomain unload, and integrates with the dispose pattern. No finalizer needed in your wrapper type.

Q6. What happens if a constructor throws after acquiring a resource? The object isn't fully constructed, so its Dispose may not be reachable. Solutions: (1) use SafeHandle (auto-released), (2) wrap the construction in a try/catch and dispose explicitly on error.

Q7. What's a "captive dependency"? A long-lived service holding a reference to a shorter-lived disposable. Common: singleton holds scoped DbContext — keeps it alive forever, container can't dispose. Symptoms: connection leaks, file-handle leaks. Validate via the DI container's scope verification or a test.

Q8. Should Dispose throw? Generally no — it makes cleanup paths fragile. Log and continue on error. The exception: if disposing genuinely fails in a way the caller must know about, document it.

Q9. How does using interact with goto / return / throw? The implicit finally ensures Dispose runs on any exit from the scope, including exceptions and early returns.

Q10. What's the order of disposal for nested usings? LIFO. The last-acquired resource is disposed first. Same as nested using blocks, just expressed via declaration order.

Q11. Why might you call GC.SuppressFinalize(this) even without a finalizer? You shouldn't — it's a no-op without a finalizer. Some legacy code does it as a "just in case" but adds nothing.

Q12. How does ASP.NET Core dispose IDisposable services? The DI scope tracks registered services that implement IDisposable/IAsyncDisposable. On scope dispose, they're disposed LIFO. For IAsyncDisposable, an AsyncServiceScope uses await using.


Gotchas / common mistakes

  • ⚠️ Forgetting using — leaked file handles, DB connections.
  • ⚠️ await using without async support — compile error.
  • ⚠️ using on a borrowed resource — caller still expected to dispose; double-dispose.
  • ⚠️ Throwing in Dispose — masks original exceptions.
  • ⚠️ Calling Dispose from a finalizer — runs on finalizer thread; may deadlock.
  • ⚠️ Fire-and-forget DisposeAsync — async cleanup may not complete.
  • ⚠️ Disposing in a long-lived serviceDI container handles scoped/transient; you don't.
  • ⚠️ Captive dependency — singleton holding scoped — slow leak.

Further reading