Create your own
Lesson illustration

Deterministic Resource Cleanup with IDisposable and IAsyncDisposable

Good to see you again. In the previous lesson, you followed a request’s cancellation signal through asynchronous I/O. Cancellation says that work is no longer wanted; disposal ensures that resources acquired for that work are released at a known point, whether the work succeeds, fails, or is cancelled.

This lesson focuses on deterministic cleanup with IDisposable, IAsyncDisposable, using, and await using. The practical goal is to make the game backend release files, HTTP responses, streams, transactions, and similar scarce resources reliably—without treating the garbage collector as a lifecycle policy.


Garbage collection is not resource cleanup

The garbage collector manages managed memory. It decides when unreachable objects’ memory can be reclaimed. That is useful, but it does not give an application a prompt, predictable point at which to release a file handle, database connection, socket, transaction, or native handle.

A resource can be represented by a perfectly ordinary managed object while still controlling something scarce outside managed memory:

  • A FileStream owns an operating-system file handle.
  • An HttpResponseMessage may hold a network connection and response content.
  • A database connection or transaction holds server-side and network resources.
  • A CancellationTokenSource can own timers and registrations.

Deterministic cleanup means your code establishes exactly when it is finished with such a resource. In C#, that normally means one of two contracts:

ContractCleanup methodConsumer syntaxAppropriate when
IDisposableDispose()usingCleanup is synchronous.
IAsyncDisposableDisposeAsync() returning ValueTaskawait usingCleanup itself may need asynchronous I/O.

The key word is ownership. The code that owns a disposable resource is responsible for disposing it, either directly or by transferring that responsibility unambiguously to another owner.

Watch the focused portions of Shawn Wildermuth’s Coding Shorts: IDisposable and IAsyncDisposable in C#. It establishes why managed memory and scarce resources differ, then contrasts using with await using.

Coding Shorts: IDisposable and IAsyncDisposable in C#

Watch “Coding Shorts: IDisposable and IAsyncDisposable in C#” by Shawn Wildermuth for a concise visual introduction to deterministic cleanup and the two consumption forms.

Start with why dispose to distinguish garbage-collected memory from costly external resources. Then watch using scope, focusing on how leaving a scope invokes Dispose even when control leaves through return or an exception. Finish with async disposal for the distinction between using and await using.

A useful rule for code review is:

If you create a disposable object and retain it, you normally own it. If a caller or DI container provides it, you normally borrow it.

For example, an endpoint that calls HttpClient.SendAsync receives a new HttpResponseMessage that it owns and must dispose. But an HttpClient injected through constructor injection is owned by the configured IHttpClientFactory infrastructure; the consumer should not wrap that injected client in using.

The same applies to common ASP.NET Core services:

  • A DI-created scoped DbContext is disposed at the end of its scope by the container.
  • A singleton supplied by DI is disposed when the host shuts down.
  • A response, stream, command, reader, transaction, or cancellation source that your method creates is usually your responsibility.

using creates a cleanup boundary

A using statement is not merely convenient syntax. Conceptually, it puts the cleanup call in a finally block. That matters because finally runs when normal execution finishes, when a method returns early, and when an exception or cancellation escapes the body.

Consider an outbound catalog call in the profile-refresh workflow from the previous lesson:

public async Task<GameDefinition> GetDefinitionAsync(
    string gameId,
    CancellationToken cancellationToken)
{
    using var request = new HttpRequestMessage(
        HttpMethod.Get,
        $"/games/{Uri.EscapeDataString(gameId)}");

    using var response = await _httpClient.SendAsync(
        request,
        HttpCompletionOption.ResponseHeadersRead,
        cancellationToken);

    response.EnsureSuccessStatusCode();

    await using var contentStream = await response.Content.ReadAsStreamAsync(
        cancellationToken);

    var definition = await JsonSerializer.DeserializeAsync<GameDefinition>(
        contentStream,
        cancellationToken: cancellationToken);

    return definition ?? throw new InvalidOperationException(
        "The game catalog returned an empty response.");
}

There are three deliberate ownership decisions here:

  1. request was created by the method, so the method disposes it.
  2. response was returned by SendAsync, so the method disposes it.
  3. contentStream is locally acquired and can be asynchronously disposed, so the method uses await using.

If SendAsync throws because the request is cancelled, or deserialization fails because the catalog returns malformed JSON, scopes already entered still clean up in reverse order. This is one reason it is valuable to acquire a resource immediately before its smallest useful scope.

Two syntax forms are worth knowing:

using (var response = await _httpClient.SendAsync(
    request,
    cancellationToken))
{
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

This block form creates a narrow, explicit scope. It is useful when the resource must be released before the rest of the method continues.

using var request = new HttpRequestMessage(HttpMethod.Get, "/games");
using var response = await _httpClient.SendAsync(
    request,
    cancellationToken);

// Use request and response here.
// Both are disposed at the end of the enclosing scope.

A using declaration is often clearer in a short endpoint or application-service method. Its scope is the remainder of the enclosing block, not just the next few lines.

Do not manually call Dispose() on a variable that is already governed by using. That creates an unclear double-disposal path. Many framework implementations tolerate repeated disposal, but that tolerance should not become your lifecycle design.

Read the opening sections of Microsoft Learn’s Implement a Dispose method. They clarify the ownership rule and show why explicit disposal and finalization are different paths.

Implement a Dispose method - .NET

Read Microsoft Learn’s “Implement a Dispose method” to consolidate the ownership and cascading-cleanup rules behind ordinary using scopes.

In “Cascade dispose calls,” focus on the rule that a containing object should dispose resources it creates and owns; contrast it with dependencies that are only borrowed. In “Dispose() and Dispose(bool),” read the cleanup-path explanation to understand why managed state is cleaned only during deterministic disposal, whereas direct unmanaged cleanup must also be safe for finalization. In “Implement the dispose pattern,” note the distinction between a straightforward managed-resource wrapper and the much rarer case of direct unmanaged-resource ownership.

Cascading disposal in a small application type

If a class creates and keeps a disposable field, its lifetime should include that field’s lifetime. A sealed class that wraps only managed disposable objects can often be simple:

public sealed class ReplayArchiveWriter : IDisposable
{
    private readonly FileStream _stream;

    public ReplayArchiveWriter(string path)
    {
        _stream = File.Create(path);
    }

    public void Append(ReadOnlySpan<byte> replayEvent)
    {
        _stream.Write(replayEvent);
    }

    public void Dispose()
    {
        _stream.Dispose();
    }
}

Its consumer communicates the boundary clearly:

using var archive = new ReplayArchiveWriter("match-481.replay");

archive.Append(eventOne);
archive.Append(eventTwo);

ReplayArchiveWriter owns _stream because it created it. Its caller owns the ReplayArchiveWriter because it created that. Disposal therefore cascades from caller to wrapper to stream.

Do not add a finalizer to this class. The class does not directly own a raw unmanaged handle; it owns a managed FileStream, which already encapsulates that complexity. A finalizer runs nondeterministically and adds GC cost. If you genuinely work directly with unmanaged handles, prefer a SafeHandle wrapper rather than writing your own finalizer.


await using preserves the same guarantee for asynchronous cleanup

Some resources need asynchronous work to close cleanly: flushing buffered output, sending a protocol close frame, or ending a transaction/session without blocking a thread. Such types implement IAsyncDisposable.

Use await using when consuming them:

public static async Task WriteSnapshotAsync(
    PlayerSnapshot snapshot,
    string path,
    CancellationToken cancellationToken)
{
    await using var stream = new FileStream(
        path,
        FileMode.Create,
        FileAccess.Write,
        FileShare.None,
        bufferSize: 64 * 1024,
        options: FileOptions.Asynchronous);

    await JsonSerializer.SerializeAsync(
        stream,
        snapshot,
        cancellationToken: cancellationToken);
}

The lifecycle remains the same:

  • FileStream is acquired.
  • Serialization either completes or throws, including through OperationCanceledException.
  • The compiler awaits DisposeAsync() in a generated asynchronous finally path.
  • The method then completes, fails, or propagates cancellation.

This connection with the prior lesson matters: cancellation does not bypass disposal. A cancelled request still needs its response, stream, transaction, and other acquired resources cleaned up. Also, disposal is normally cleanup that must happen even after cancellation has been requested. DisposeAsync() has no CancellationToken parameter for that reason.

Database transactions provide another common pattern:

public async Task AwardSeasonPointsAsync(
    PlayerProgress progress,
    int points,
    CancellationToken cancellationToken)
{
    await using var transaction = await _dbContext.Database
        .BeginTransactionAsync(cancellationToken);

    progress.AddSeasonPoints(points);

    await _dbContext.SaveChangesAsync(cancellationToken);

    await transaction.CommitAsync(cancellationToken);
}

If SaveChangesAsync fails or observes cancellation, control leaves the method and the transaction is disposed. In typical database providers, disposing an uncommitted transaction rolls it back. Still, do not confuse cleanup with a complete business-consistency strategy: later modules will cover transactions, concurrency, and idempotency more deeply.

Microsoft Learn’s Implement a DisposeAsync method is the reference for both consuming and implementing asynchronous cleanup.

Implement a DisposeAsync method - .NET | Microsoft Learn

Read Microsoft Learn’s “Implement a DisposeAsync method” for the standard patterns when a type must support asynchronous cleanup, including the cases where it also supports synchronous disposal.

In “Implement both dispose and async dispose patterns,” read the cascading-cleanup rationale and notice that the synchronous and asynchronous paths choose the compatible cleanup operation. In “Using async disposable,” study the await using statement and declaration examples. Then read “Stacked usings,” especially the scoping warning: acquire multiple async resources in explicit nested scopes or separate await using declarations so an exception during later construction cannot strand an earlier resource.

A subtle but practical point: an asynchronous method does not automatically require await using. Choose based on the resource’s cleanup contract:

  • HttpResponseMessage is normally disposed with using.
  • An IAsyncDisposable transaction or stream can be disposed with await using.
  • A type implementing both may be used with either form, but in asynchronous I/O code, await using gives the type its asynchronous cleanup path.

Implement the contract your type actually needs

Most application types should be sealed unless inheritance is a deliberate part of the design. Sealing makes cleanup easier because no derived type can add resources that require its own disposal logic.

A sealed type with asynchronous cleanup

Suppose a game-session client owns a stream whose closing operation can be asynchronous:

public sealed class MatchEventUploader : IAsyncDisposable
{
    private Stream? _stream;

    public MatchEventUploader(Stream stream)
    {
        _stream = stream;
    }

    public Task UploadAsync(
        ReadOnlyMemory<byte> eventPayload,
        CancellationToken cancellationToken)
    {
        var stream = _stream ?? throw new ObjectDisposedException(
            nameof(MatchEventUploader));

        return stream.WriteAsync(eventPayload, cancellationToken).AsTask();
    }

    public async ValueTask DisposeAsync()
    {
        var stream = Interlocked.Exchange(ref _stream, null);

        if (stream is not null)
        {
            await stream.DisposeAsync().ConfigureAwait(false);
        }

        GC.SuppressFinalize(this);
    }
}

The call to Interlocked.Exchange ensures that only one disposal attempt takes ownership of the stream reference. It does not make simultaneous use and disposal safe; a type that permits concurrent UploadAsync and DisposeAsync needs a broader concurrency design. For many request-scoped objects, the simpler expectation is that consumers finish using the object before disposing it.

The caller must select the asynchronous lifecycle:

await using var uploader = new MatchEventUploader(stream);

await uploader.UploadAsync(eventPayload, cancellationToken);

A type that must support both forms

Sometimes a public type has synchronous consumers and asynchronous consumers, while its owned resource supports both contracts. Implement both interfaces and ensure either path releases ownership exactly once:

using System.Data.Common;
using System.Threading;

public sealed class GameDatabaseSession : IDisposable, IAsyncDisposable
{
    private DbConnection? _connection;

    public GameDatabaseSession(DbConnection ownedConnection)
    {
        _connection = ownedConnection;
    }

    public void Dispose()
    {
        var connection = Interlocked.Exchange(ref _connection, null);

        connection?.Dispose();

        GC.SuppressFinalize(this);
    }

    public async ValueTask DisposeAsync()
    {
        var connection = Interlocked.Exchange(ref _connection, null);

        if (connection is not null)
        {
            await connection.DisposeAsync().ConfigureAwait(false);
        }

        GC.SuppressFinalize(this);
    }
}

The parameter name ownedConnection is intentional: callers transfer ownership to the session. A constructor that merely uses a DI-provided DbConnection would need a different contract; it should generally not dispose a dependency it does not own.

For an inheritable base class, use the formal extensibility hooks:

  • protected virtual void Dispose(bool disposing) for synchronous cleanup.
  • protected virtual ValueTask DisposeAsyncCore() for asynchronous cleanup.
  • Derived classes clean their own resources and call the base implementation.

That full pattern is worthwhile for a library base class designed for inheritance. For a typical backend application type, prefer a sealed class and the simpler implementations above. Avoid adding Dispose(bool), finalizers, flags, and boilerplate merely because a code snippet exists; each part exists to solve a specific inheritance or direct-unmanaged-resource problem.


A disposal checklist for the game backend

When reviewing a method or class in the portfolio project, make cleanup decisions explicitly:

  1. Identify scarce resources. Look for streams, HTTP requests and responses, database commands/readers/transactions, cancellation sources, timers, and SDK clients with disposal contracts.
  2. Assign one owner. The creator normally owns the object unless ownership is explicitly transferred.
  3. Use the smallest useful scope. Prefer using or await using at acquisition rather than a distant manual cleanup call.
  4. Match the contract. Use using for IDisposable; use await using for resources whose asynchronous disposal path matters.
  5. Allow cleanup on every exit path. Returns, exceptions, and cancellation should all cross the same disposal boundary.
  6. Do not dispose borrowed DI services. Let ASP.NET Core’s service container dispose services it created at the configured lifetime.
  7. Keep finalizers exceptional. For direct native handles, prefer SafeHandle; for managed wrappers such as streams and database clients, deterministic disposal is the right tool.

As a short implementation habit, inspect the previous lesson’s GameCatalogClient. Keep HttpRequestMessage and HttpResponseMessage inside using scopes, then update the response-stream scope to await using when the API returns an asynchronously disposable stream. Test three paths: a successful response, malformed JSON, and an already-cancelled token. Cleanup should occur in all three cases.


Key takeaways

  • Garbage collection reclaims managed memory but does not provide timely release of scarce external resources.
  • using calls Dispose() deterministically; await using awaits DisposeAsync() deterministically.
  • Disposal is an ownership responsibility: dispose resources you create and own, not injected services you merely borrow.
  • Scope-based disposal is safe across normal completion, early return, exceptions, and cancellation.
  • A class that owns disposable members should cascade cleanup to them.
  • Prefer simple sealed implementations for ordinary application types; use extensible dispose patterns only for genuine base-class designs.
  • Finalizers are not a substitute for using and are rarely appropriate in modern application code.

Next, you will diagnose two closely related async reliability defects: sync-over-async, where blocking waits waste threads and can deadlock, and unobserved tasks, where failures escape the intended error-handling path.

Can't find a good explanation? Sign up and we'll make it for you

Sign up