📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

When should a type implement IAsyncDisposable, how does await using work, and how do you implement both IDisposable and IAsyncDisposable correctly?

IAsyncDisposable and ValueTask DisposeAsync, await using lowering, ConfigureAwait on disposal, the DisposeAsyncCore pattern, and DI async scopes.

Senior Coding Advanced async await-using dependency-injection iasyncdisposable

🎯 Key points

  • Implement IAsyncDisposable when cleanup needs asynchronous I/O
  • DisposeAsync returns ValueTask; await it once
  • await using lowers to try/finally with await DisposeAsync()
  • Use await using (resource.ConfigureAwait(false)) in libraries
  • Types supporting both use DisposeAsyncCore plus Dispose(bool) and GC.SuppressFinalize
  • Use CreateAsyncScope; sync disposal of scopes with async-only services throws
  • Never block on async work inside Dispose

⏱ 30-second answer

Implement IAsyncDisposable when cleanup involves asynchronous I/O, such as flushing a buffered stream, sending a final message or closing a network connection gracefully. DisposeAsync() returns ValueTask. await using var x = ...; compiles to try/finally with await x.DisposeAsync(), and you can write await using (x.ConfigureAwait(false)) in library code. A type that supports both should implement IDisposable and IAsyncDisposable, put async cleanup in protected virtual ValueTask DisposeAsyncCore(), and call GC.SuppressFinalize. In DI, use CreateAsyncScope(): disposing a provider or scope synchronously throws when it holds a service that implements only IAsyncDisposable.

🗣 Full answer (senior)

How I would answer this in an interview

"I implement IAsyncDisposable when cleanup itself needs asynchronous I/O: flushing buffered data to a file or network, sending a final message, gracefully closing a connection, or waiting for in-flight background work. DisposeAsync() returns a ValueTask, and await using lowers to try/finally with await x.DisposeAsync(). If a type should work in both sync and async code, I implement both interfaces: async cleanup in protected virtual ValueTask DisposeAsyncCore(), sync cleanup in Dispose(bool), and GC.SuppressFinalize in both public methods. The rule I never break is no sync-over-async in Dispose. In DI, I use CreateAsyncScope(): disposing a scope synchronously throws if it holds a service that only implements IAsyncDisposable."


1. When to implement it

Cleanup needs Implement
Only releasing handles/memory synchronously IDisposable only
Flushing/closing I/O, sending a final message, awaiting background work IAsyncDisposable
Library type used from both sync and async callers Both
Owns another IAsyncDisposable IAsyncDisposable (to dispose it properly)

2. How await using works

await using var writer = new AsyncLogWriter(path);
await writer.WriteAsync("hello");

becomes:

var writer = new AsyncLogWriter(path);
try
{
    await writer.WriteAsync("hello");
}
finally
{
    if (writer != null) await writer.DisposeAsync();
}
Detail Behaviour
Return type ValueTask: cheap when already disposed / nothing to flush
Await it Exactly once; never store or await twice
Library code await using (resource.ConfigureAwait(false)) to avoid capturing a context
Iterators await using inside an async IAsyncEnumerable<T> disposes when enumeration ends

3. Implementing both interfaces (unsealed type)

Member Responsibility
public async ValueTask DisposeAsync() await DisposeAsyncCore().ConfigureAwait(false); Dispose(disposing: false); GC.SuppressFinalize(this);
protected virtual ValueTask DisposeAsyncCore() Async cleanup of managed resources; derived classes override and await base.DisposeAsyncCore()
public void Dispose() Dispose(disposing: true); GC.SuppressFinalize(this);
protected virtual void Dispose(bool disposing) Sync cleanup; when disposing == false (from DisposeAsync) release only unmanaged state

The Dispose(false) call from DisposeAsync lets unmanaged cleanup live in one place. For a sealed type, a single DisposeAsync() (plus a simple Dispose() if sync callers exist) is enough.

4. DI and hosting

Situation Behaviour
ASP.NET Core request scope Disposed asynchronously for you
Generic host shutdown Host and root provider disposed asynchronously
Your own scope in a background service Use await using var scope = provider.CreateAsyncScope();
using var scope = provider.CreateScope() with an IAsyncDisposable-only service Throws InvalidOperationException on dispose
Service implements both Sync scope disposal calls Dispose(), so async cleanup is skipped

5. Rules

  • Never block on async work inside Dispose() (.Result, .Wait(), GetAwaiter().GetResult()).
  • Make DisposeAsync idempotent and non-throwing for normal failures.
  • Flush with a timeout/cancellation where shutdown time matters (e.g. a shutdown deadline).
  • After disposal, members throw ObjectDisposedException.

Production example

Our [telemetry exporter] buffered events in memory and, in Dispose(), flushed them over HTTP with .GetAwaiter().GetResult(). Under load, shutdowns took [tens of seconds] or hung, and logs showed thread pool starvation warnings. A dump taken during shutdown showed many threads blocked inside Dispose waiting on the HTTP call. I implemented IAsyncDisposable, moved the flush to DisposeAsyncCore() with await and a [5-second] timeout from the host's shutdown token, kept a minimal non-blocking Dispose() that just drops the buffer and logs a warning, and verified the generic host disposed it asynchronously. Shutdown dropped to [under two seconds], and we added a graceful-shutdown-under-load test to CI.

🔍 What the interviewer is checking

The interviewer checks modern resource management in async code.

Green flags: when to implement it, ValueTask, await using lowering, ConfigureAwait pattern, implementing both interfaces with DisposeAsyncCore, DI async scopes and the synchronous-dispose exception.

Red flags: sync-over-async in Dispose; uses using on async-only types; unaware of CreateAsyncScope.

📚 Learn in depth

1. ValueTask and why disposal uses it

Most DisposeAsync calls either have nothing to do (already disposed, empty buffer) or complete synchronously. ValueTask avoids allocating a Task for those cases. Rules for consuming a ValueTask:

  • Await it once.
  • Don't .Result it before completion.
  • Don't store it and await later from multiple places.

CA2012 flags misuse.

2. ConfigureAwait with await using

TaskAsyncEnumerableExtensions.ConfigureAwait(this IAsyncDisposable, bool) returns a ConfiguredAsyncDisposable, which await using accepts. Because the wrapper isn't the resource, declare the resource separately:

var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
await using (stream.ConfigureAwait(false))
{
    await stream.WriteAsync(data).ConfigureAwait(false);
}

Writing await using (var s = stream.ConfigureAwait(false)) compiles, but s is the wrapper, not the FileStream. In ASP.NET Core app code there's no synchronisation context, so this is mostly relevant for libraries.

3. A derived class with async cleanup

public class BufferedSink : IDisposable, IAsyncDisposable
{
    private readonly Stream _stream;
    private bool _disposed;

    public BufferedSink(Stream stream) => _stream = stream;

    public async ValueTask DisposeAsync()
    {
        await DisposeAsyncCore().ConfigureAwait(false);
        Dispose(disposing: false);
        GC.SuppressFinalize(this);
    }

    protected virtual async ValueTask DisposeAsyncCore()
    {
        if (_disposed) return;
        _disposed = true;
        await _stream.FlushAsync().ConfigureAwait(false);
        await _stream.DisposeAsync().ConfigureAwait(false);
    }

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

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;
        _disposed = true;
        if (disposing) _stream.Dispose();
    }
}

public sealed class CompressingSink : BufferedSink
{
    private readonly GZipStream _zip;
    public CompressingSink(Stream s) : base(s) => _zip = new GZipStream(s, CompressionLevel.Fastest, leaveOpen: true);

    protected override async ValueTask DisposeAsyncCore()
    {
        try { await _zip.DisposeAsync().ConfigureAwait(false); }   // write gzip footer first
        finally { await base.DisposeAsyncCore().ConfigureAwait(false); }
    }

    protected override void Dispose(bool disposing)
    {
        try { if (disposing) _zip.Dispose(); }
        finally { base.Dispose(disposing); }
    }
}

4. How the DI container chooses

ServiceProviderEngineScope tracks disposables in creation order and, on disposal:

Scope disposed with Service implements Container calls
DisposeAsync IAsyncDisposable (with or without IDisposable) DisposeAsync
DisposeAsync only IDisposable Dispose
Dispose IDisposable (with or without async) Dispose
Dispose only IAsyncDisposable throws InvalidOperationException

5. Types in the BCL with async disposal

Type DisposeAsync does
FileStream, BufferedStream Flushes asynchronously, then closes
StreamWriter (.NET Core 3.0+) Flushes its buffer and the stream asynchronously
Utf8JsonWriter Flushes to the underlying stream
DbConnection, DbCommand, DbDataReader Async close where the provider implements it
Timer Waits for in-flight callbacks to finish
CancellationTokenRegistration Waits for a running callback
AsyncServiceScope, ServiceProvider Async disposal of tracked services
IAsyncEnumerator<T> Runs the iterator's finally/await using cleanup

6. Shutdown deadlines

Async disposal at shutdown runs within the host's shutdown timeout (HostOptions.ShutdownTimeout, 30 seconds by default in recent versions). Flushes that call remote services should respect a cancellation token or timeout so a slow dependency can't stall shutdown indefinitely.

7. Version notes

Version Change
.NET Core 3.0 / C# 8 IAsyncDisposable, await using, async disposal on streams
.NET 5 DI scopes support async disposal broadly
.NET 6 CreateAsyncScope() / AsyncServiceScope
.NET 7 ObjectDisposedException.ThrowIf
.NET 8–10 More BCL types with async disposal; pattern unchanged

8. Try it yourself

  1. Run the code example: the log is flushed by DisposeAsync, and the synchronous scope dispose throws for the async-only service.
  2. Change AsyncOnlyExporter to implement both interfaces, dispose a sync scope, and confirm only Dispose() runs.
  3. Write a Dispose() that does Task.Delay(2000).GetAwaiter().GetResult() and dispose 200 of them in parallel; watch thread pool starvation with dotnet-counters.
  4. Implement the CompressingSink hierarchy above and confirm the gzip file is valid after await using.
  5. Add a Timer whose callback takes 1 second, then compare timer.Dispose() vs await timer.DisposeAsync() timing.
  6. Enable CA2012 and CA2215 and introduce a mistake each catches.

💻 Code

using System.Text;
using Microsoft.Extensions.DependencyInjection;

await using (var log = new AsyncLogWriter("app.log"))
{
    await log.WriteAsync("started");
    await log.WriteAsync("finished");
} // DisposeAsync flushes the buffer asynchronously
Console.WriteLine(File.ReadAllText("app.log"));

var services = new ServiceCollection().AddScoped<AsyncOnlyExporter>().BuildServiceProvider();

// Correct: async scope
await using (AsyncServiceScope scope = services.CreateAsyncScope())
    scope.ServiceProvider.GetRequiredService<AsyncOnlyExporter>();

// Wrong: synchronous dispose of a scope holding an IAsyncDisposable-only service
try
{
    using IServiceScope syncScope = services.CreateScope();
    syncScope.ServiceProvider.GetRequiredService<AsyncOnlyExporter>();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Sync dispose failed: {ex.Message}");
}

public class AsyncLogWriter : IDisposable, IAsyncDisposable
{
    private readonly FileStream _file;
    private readonly StringBuilder _buffer = new();
    private bool _disposed;

    public AsyncLogWriter(string path) => _file = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, 4096, useAsync: true);

    public Task WriteAsync(string line)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        _buffer.AppendLine(line);
        return Task.CompletedTask;
    }

    public async ValueTask DisposeAsync()
    {
        await DisposeAsyncCore().ConfigureAwait(false);
        Dispose(disposing: false);
        GC.SuppressFinalize(this);
    }

    protected virtual async ValueTask DisposeAsyncCore()
    {
        if (_disposed) return;
        _disposed = true;
        await _file.WriteAsync(Encoding.UTF8.GetBytes(_buffer.ToString())).ConfigureAwait(false);
        await _file.DisposeAsync().ConfigureAwait(false);
    }

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

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed || !disposing) return;
        _disposed = true;
        _file.Write(Encoding.UTF8.GetBytes(_buffer.ToString()));   // synchronous fallback
        _file.Dispose();
    }
}

public sealed class AsyncOnlyExporter : IAsyncDisposable
{
    public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

⚠ Common mistakes

1. Sync-over-async inside Dispose()

What happens: FlushAsync().GetAwaiter().GetResult() in Dispose. Why it is wrong: blocks threads, can deadlock under a synchronisation context, and starves the thread pool at shutdown. Do instead: implement IAsyncDisposable and use await using.

2. Disposing DI scopes synchronously in async code

What happens: using var scope = provider.CreateScope(); in a background service. Why it is wrong: async-only services throw on dispose; dual services skip async cleanup. Do instead: await using var scope = provider.CreateAsyncScope();.

3. Forgetting the base class's async cleanup

What happens: a derived DisposeAsyncCore doesn't call base.DisposeAsyncCore(). Why it is wrong: base resources never get released or flushed. Do instead: call it in a finally block.

4. Using plain using on an IAsyncDisposable type that also implements IDisposable

What happens: the code compiles and calls sync Dispose. Why it is wrong: async flush is skipped or done by blocking. Do instead: await using in async methods.

5. Awaiting the ValueTask twice or storing it

What happens: var t = x.DisposeAsync(); await t; await t;. Why it is wrong: ValueTask may be backed by a pooled source; double-await is undefined behaviour. Do instead: await once, directly.

6. Throwing from DisposeAsync

What happens: a failed flush throws out of await using. Why it is wrong: like sync Dispose, it masks the original exception from the body. Do instead: handle/log in disposal; offer an explicit FlushAsync/CompleteAsync for callers who need errors.

7. No timeout on remote flushes during shutdown

What happens: DisposeAsync calls a slow remote endpoint without cancellation. Why it is wrong: shutdown hangs until the host timeout and work is cut off abruptly. Do instead: use a bounded timeout or the host's stopping token.

8. Using the ConfigureAwait wrapper as the resource

What happens: await using (var s = stream.ConfigureAwait(false)) { s.Write(...) }. Why it is wrong: s is a ConfiguredAsyncDisposable, not the stream. Do instead: declare the resource first, then await using (resource.ConfigureAwait(false)).

9. Implementing IAsyncDisposable with nothing async to do

What happens: every class gets DisposeAsync => ValueTask.CompletedTask. Why it is wrong: adds API surface and forces await using on callers for no benefit. Do instead: implement it only when cleanup is genuinely asynchronous or you own async-disposable resources.

10. Not making DisposeAsync idempotent

What happens: both an owner and the DI container dispose the object. Why it is wrong: double flush, double close, ObjectDisposedException from inner resources. Do instead: guard with a disposed flag (thread-safe if needed).

🏛 Senior insight: trade-offs

Async disposal matters most at boundaries: shutdown, streaming and network clients.

Scenario Recommendation
Type flushes or closes I/O Implement IAsyncDisposable
Type holds only sync resources IDisposable only
Library type used in sync and async code Implement both with the pattern
Background services and jobs CreateAsyncScope per unit of work

Seniors test graceful shutdown under load, because that is where blocking disposal bugs appear.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate implements Dispose(bool) Why: Checks the synchronous pattern
    How to answer

    Implement IDisposable when your type owns something that must be released deterministically: an unmanaged resource (usually via SafeHandle) or another IDisposable. For a sealed class, a simple Dispose() that disposes owned fields is enough. For unsealed classes use the pattern Dispose() calling protected virtual Dispose(bool disposing) and GC.SuppressFinalize(this). Make Dispose idempotent and non-throwing, and throw ObjectDisposedException (ObjectDisposedException.ThrowIf) from members used after disposal. The owner disposes: whoever creates an object disposes it, unless ownership is explicitly transferred. The DI container disposes what it creates, at the end of the scope or the app.

  2. When: Candidate explains await using lowering Why: Checks using statement pitfalls
    How to answer

    using (var x = ...) { } and the C# 8 using declaration using var x = ...; both compile to try { ... } finally { x?.Dispose(); }. A declaration disposes at the end of the enclosing scope; several declarations are disposed in reverse order. For ref structs, using works with a pattern-based Dispose() method. Common bugs: returning or capturing an object that the using disposes, returning a lazy IEnumerable (LINQ or yield) built on a disposed resource, holding a resource longer than needed because a using declaration lives until the end of a long method, and an exception thrown by Dispose masking the original exception.

🛠 Real-world problems: how you handle them

Telemetry exporter deadlocked on shutdown by flushing synchronously
Situation
A telemetry exporter buffered events and flushed them over HTTP in `Dispose()`.
Signals
Shutdowns under load took [tens of seconds] or hung; thread pool starvation warnings in logs.
Investigation
A dump during shutdown showed threads blocked in `GetAwaiter().GetResult()` inside `Dispose`.
Resolution
Implemented `IAsyncDisposable` with an awaited flush and let the host dispose it asynchronously.
Prevention
Graceful shutdown test under load in CI and an analyser rule against blocking calls in `Dispose`.

How to tell it (STAR)

Our [telemetry exporter] flushed over HTTP inside Dispose using GetResult, which blocked threads at shutdown. I moved the flush into DisposeAsync and made sure the host disposed it asynchronously. Shutdown time dropped from [tens of seconds] to [under two].

↩ Usually asked after

More in Garbage Collection

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me