IAsyncDisposable and ValueTask DisposeAsync, await using lowering, ConfigureAwait on disposal, the DisposeAsyncCore pattern, and DI async scopes.
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.
"I implement
IAsyncDisposablewhen 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 aValueTask, andawait usinglowers totry/finallywithawait x.DisposeAsync(). If a type should work in both sync and async code, I implement both interfaces: async cleanup inprotected virtual ValueTask DisposeAsyncCore(), sync cleanup inDispose(bool), andGC.SuppressFinalizein both public methods. The rule I never break is no sync-over-async inDispose. In DI, I useCreateAsyncScope(): disposing a scope synchronously throws if it holds a service that only implementsIAsyncDisposable."
| 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) |
await using worksawait 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 |
| 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.
| 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 |
Dispose() (.Result, .Wait(), GetAwaiter().GetResult()).DisposeAsync idempotent and non-throwing for normal failures.ObjectDisposedException.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.
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.
ValueTask and why disposal uses itMost 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:
.Result it before completion.CA2012 flags misuse.
ConfigureAwait with await usingTaskAsyncEnumerableExtensions.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.
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); }
}
}
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 |
| 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 |
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.
| 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 |
DisposeAsync, and the synchronous scope dispose throws for the async-only service.AsyncOnlyExporter to implement both interfaces, dispose a sync scope, and confirm only Dispose() runs.Dispose() that does Task.Delay(2000).GetAwaiter().GetResult() and dispose 200 of them in parallel; watch thread pool starvation with dotnet-counters.CompressingSink hierarchy above and confirm the gzip file is valid after await using.Timer whose callback takes 1 second, then compare timer.Dispose() vs await timer.DisposeAsync() timing.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;
}
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.
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();.
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.
using on an IAsyncDisposable type that also implements IDisposableWhat 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.
ValueTask twice or storing itWhat 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.
DisposeAsyncWhat 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.
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.
ConfigureAwait wrapper as the resourceWhat 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)).
IAsyncDisposable with nothing async to doWhat 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.
DisposeAsync idempotentWhat 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).
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.
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.
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.
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].
Last updated 31 Oct 2026.