A Practical Decision Table for C# async/await - Task.Run and ConfigureAwait

· · C#, async/await, .NET, Design

We use C#’s async / await every day, but what trips people up in practice is not the syntax itself - it is which pattern to choose in which situation. The questions people search for most are decision questions: when to use Task.Run, where to put ConfigureAwait(false), and whether fire-and-forget should be allowed.

  • Wrapping an I/O wait in Task.Run
  • await-ing independent operations one at a time, serially
  • Casually adding fire-and-forget and losing track of exceptions and shutdown timing
  • Sprinkling ConfigureAwait(false) everywhere indiscriminately
  • Choosing ValueTask purely because “it sounds lightweight”

Rather than memorizing each of these individually, you will go astray less if you start by identifying what kind of work it is.

In this article, assuming mainly general C# / .NET application development on .NET 6 and later, we lay out the async / await patterns in the order that makes decisions easiest.

The kinds of development we have in mind include:

  • Desktop apps such as WinForms / WPF
  • ASP.NET Core web apps / APIs
  • Workers / background services
  • Console apps
  • Reusable class libraries

The code in this article is published on GitHub as a complete buildable and runnable sample set (a library, a console demo, and unit tests verifying each pattern in the decision table).

csharp-async-await-best-practices - komurasoft-blog-samples (GitHub)

Table of Contents

  1. The Conclusion First (In One Line)
  2. Terms Used in This Article
    • 2.1. Terms to Distinguish First
    • 2.2. Frequently Appearing Terms
  3. The Decision Table to Look at First
    • 3.1. The Big Picture
    • 3.2. For I/O Waits, await the async API Directly
    • 3.3. For Heavy CPU Work, Choose Where to Use Task.Run
    • 3.4. For Multiple Independent Operations, Task.WhenAll
    • 3.5. To Use Whichever Finishes First, Task.WhenAny
    • 3.6. For Many Items With Limited Parallelism, Parallel.ForEachAsync or SemaphoreSlim
    • 3.7. To Process in Order, Channel<T>
    • 3.8. To Run at a Fixed Interval, PeriodicTimer
    • 3.9. For Data Arriving Incrementally, IAsyncEnumerable<T>
    • 3.10. For Asynchronous Disposal, await using
    • 3.11. For Mutual Exclusion Across await, SemaphoreSlim
    • 3.12. Write await Differently in UI / App Code / Libraries
  4. Basic Writing Rules
    • 4.1. Return Task / Task<T> First
    • 4.2. async void Only for Event Handlers
    • 4.3. Accept a CancellationToken and Pass It Downstream
    • 4.4. Keep Async APIs Asynchronous All the Way Down
    • 4.5. When Creating Tasks With LINQ, Materialize With ToArray / ToList
  5. Common Anti-Patterns
  6. A Code Review Checklist
  7. A Rough Guide to Choosing
  8. Conclusion
  9. References

1. The Conclusion First (In One Line)

  • async / await is a way of writing code so threads are not blocked while waiting - not a mechanism that automatically speeds everything up or magically moves work to another thread
  • First determine whether the work is an I/O wait or CPU computation
  • For I/O waits, the basic move is to await the async API directly
  • For CPU work, think about where that computation should run. Task.Run can help in UI code, but in ASP.NET Core request processing, wrapping work in Task.Run and immediately awaiting it should generally be avoided
  • For multiple independent operations, consider Task.WhenAll before awaiting them serially
  • With many items, do not fire everything at once via Task.WhenAll - decide a cap on parallelism
  • fire-and-forget looks easy but is hard to manage. If you genuinely need to decouple a job’s lifetime from the caller, hand it to a managed place such as a Channel or a HostedService - that is more stable
  • For return types, start with Task / Task<T>. Choose ValueTask only after measurement shows the need
  • ConfigureAwait(false) is a strong option in general-purpose library code, but plain await is fine in UI and application-side code
  • async void is never used outside event handlers

In short, the most important thing around async / await is not falling into “Task.Run by default,” “fire-and-forget by default,” or “ValueTask by default.”

Start with:

  1. What is this operation actually waiting on?
  2. Who owns this operation’s lifetime?
  3. Where is concurrency being controlled?

Looking at these three reduces the hesitation considerably.

2. Terms Used in This Article

2.1. Terms to Distinguish First

Separating these two at the start removes most of the confusion.

Term Meaning here
I/O-bound Work centered on waiting for external completion - HTTP, DB, files, sockets
CPU-bound Work centered on CPU computation itself - compression, image processing, hashing, heavy transforms

async / await shines for I/O waits, where the thread can be returned to other work while waiting. CPU computation, by contrast, is time actually spent computing rather than waiting, so the questions become which thread it runs on and how the degree of parallelism is decided.

2.2. Frequently Appearing Terms

Term Meaning here
Blocking Continuing to occupy a thread while waiting for completion
fire-and-forget Starting work without the caller awaiting its completion
SynchronizationContext The mechanism for “returning to the original execution location,” e.g. in UI
Backpressure A mechanism that makes the writer wait when input is coming too fast, preventing unbounded growth

The especially important point is that asynchrony and parallelism are different things.

  • Asynchrony: about how you wait
  • Parallelism: about doing things at the same time

When these two get conflated, you start wanting Task.Run everywhere. This is the first fork in the road.

3. The Decision Table to Look at First

3.1. The Big Picture

Start with this table and the broad direction usually falls into place.

Situation Reach for first What to watch
Waiting on HTTP / DB / files await the async API directly Do not wrap in Task.Run
Heavy computation that must not freeze the UI Task.Run Move CPU work off the UI thread
ASP.NET Core request processing Plain await Do not Task.Run and immediately await
A few independent async operations Task.WhenAll Start everything first, then wait together
Use only whichever finishes first Task.WhenAny Think about cancelling the rest and collecting exceptions
Many items, need a cap Parallel.ForEachAsync / SemaphoreSlim Make the parallelism explicit
Background work processed in order Channel<T> Think about bounded queues and backpressure
Async work at a fixed interval PeriodicTimer Keep to one timer, one consumer
Process results bit by bit IAsyncEnumerable<T> / await foreach Proceed without waiting for everything
Asynchronous disposal needed await using Use IAsyncDisposable
Mutual exclusion across await SemaphoreSlim.WaitAsync Always Release in try/finally
General-purpose library code Consider ConfigureAwait(false) Avoid depending on UI / app-specific contexts
YesNoYesUI event / desktopASP.NET Core requestWorker / backgroundNoWait for all to finishUse whichever finishes firstMany itemsProcess in orderFixed intervalSequential streamThe work you want to doWaiting on external I/O?await the async API directlyHeavy CPU computation?Where does it run?Consider Task.RunDo not wrap in Task.RunIf needed, move to a worker or queueRun in place ormake the parallelism explicitHandling multiple jobs?Task.WhenAllTask.WhenAnyParallel.ForEachAsyncor SemaphoreSlimChannel&lt;T&gt;PeriodicTimerIAsyncEnumerable&lt;T&gt;

Below, we look at each pattern in turn.

3.2. For I/O Waits, await the async API Directly

This is the most fundamental pattern.

For HTTP, DB, file reads/writes and the like, first check whether an async version of the API exists. If it does, the basic move is to await it directly.

public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await File.ReadAllTextAsync(path, cancellationToken);
}

What you want to avoid here is wrapping already-async I/O in Task.Run.

// Bad example
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}

This merely re-dispatches an I/O wait onto another thread - it muddies the code with no benefit.

  • For I/O waits, Task.Run is unnecessary
  • Look for an async API first
  • If you receive a token, pass it straight downstream

This is well-trodden ground.

3.3. For Heavy CPU Work, Choose Where to Use Task.Run

Task.Run pays off when you want to move CPU computation off the current thread.

For example, running a heavy computation directly in a UI event handler freezes the screen. In that case, Task.Run is the natural fit.

public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        cancellationToken.ThrowIfCancellationRequested();

        using var sha256 = System.Security.Cryptography.SHA256.Create();
        byte[] current = data;

        for (int i = 0; i < repeat; i++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            current = sha256.ComputeHash(current);
        }

        return current;
    }, cancellationToken);
}

The important question, though, is where you are calling from.

  • UI such as WinForms / WPF: there are situations where Task.Run helps
  • ASP.NET Core request processing: generally avoid Task.Run immediately followed by await
  • Worker / background processing: run in place, or design the degree of parallelism

ASP.NET Core request processing already runs on the ThreadPool, so inserting one layer of Task.Run and awaiting it right away tends to add nothing but extra scheduling.

So in ASP.NET Core, this way of thinking serves better:

  • For I/O waits, plain await
  • For short CPU work, run it in place
  • For long work, or work to be decoupled from the request’s lifetime, hand it to a queue or HostedService

Note that when calling an API that only has a sync version from the UI, you may use Task.Run for UI responsiveness. But this is not “async I/O” - it is just dodging the problem by occupying one thread. On the server side, as in ASP.NET Core, this escape hatch fundamentally does not scale.

3.4. For Multiple Independent Operations, Task.WhenAll

Code that waits on multiple independent async operations one at a time comes up all the time.

// Independent operations made serial
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);

If they do not depend on each other, it is more natural to start them all first and wait at the end, together.

public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    Task<string>[] tasks = urls
        .Select(url => _httpClient.GetStringAsync(url, cancellationToken))
        .ToArray();

    return await Task.WhenAll(tasks);
}

The key is ToArray(). LINQ is lazily evaluated, so after just a Select, nothing may have been enumerated yet. Materializing with ToArray() or ToList() ensures all tasks have started at that point.

Task 3Task 2Task 1CallerTask 3Task 2Task 1CallerStartStartStartawait Task.WhenAll(...)DoneDoneDone

This pattern suits cases where:

  • The item count is small or moderate
  • You want to wait for them all together
  • Running them all simultaneously without a cap is acceptable

With many items, it is safer to put a cap on parallelism, as in 3.6 below.

3.5. To Use Whichever Finishes First, Task.WhenAny

For example, when you want to use whichever of several mirrors responds first, Task.WhenAny is the clear choice.

public async Task<byte[]> DownloadFromFirstMirrorAsync(
    IReadOnlyList<string> urls,
    CancellationToken cancellationToken)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    Task<byte[]>[] tasks = urls
        .Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
        .ToArray();

    Task<byte[]> winner = await Task.WhenAny(tasks);
    cts.Cancel();

    try
    {
        return await winner;
    }
    finally
    {
        try
        {
            await Task.WhenAll(tasks);
        }
        catch
        {
            // Collect the cancellations and failures of the non-winners
        }
    }
}

The thing to watch here is that WhenAny only returns one winner. The remaining operations keep running unless you do something about them.

So you need to decide in advance:

  • Should the rest be cancelled?
  • Do you want to observe their exceptions?

Task.WhenAny is useful, but it requires a bit more design than WhenAll. It stays clear if you pick it only when “the first one is all I need.”

3.6. For Many Items With Limited Parallelism, Parallel.ForEachAsync or SemaphoreSlim

Task.WhenAll runs every task you created at once. So with a large number of items, HTTP connections, DB connections, memory usage, and load on external services all jump together.

In those cases, it is more stable to decide how many run at the same time.

Parallel.ForEachAsync makes that intent very readable.

public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = 8,
        CancellationToken = cancellationToken
    };

    await Parallel.ForEachAsync(
        urls.Select((url, index) => (url, index)),
        options,
        async (item, token) =>
        {
            string html = await _httpClient.GetStringAsync(item.url, token);
            string path = Path.Combine("cache", $"{item.index}.html");
            await File.WriteAllTextAsync(path, html, token);
        });
}

This pattern suits cases where:

  • There are many items
  • Each item’s processing is independent
  • But firing them all at once must be avoided

If you want finer control, there is also the SemaphoreSlim approach - for example, “at most 4 concurrent calls to this particular external API.”

So:

  • A handful of items → Task.WhenAll
  • Large volumes → Parallel.ForEachAsync or SemaphoreSlim

With this split, you rarely go far wrong.

3.7. To Process in Order, Channel<T>

Sometimes you want to detach work from the caller - work that “does not have to finish right now, but must definitely be processed.” Sending email, forwarding logs, webhook post-processing, file conversion, and so on.

If you toss these off with a bare Task.Run, the following become vague:

  • Where do exceptions get observed?
  • Do we wait for it at shutdown?
  • How much do we accept when volume grows?

This kind of work is easier to manage by putting it on a queue and having a dedicated consumer process it in order.

YesNoproducerWriteAsyncIs there room in the queue?Enters the ChannelWaits until there is roomconsumer ReadAsyncawait and process in order

Channel<T> lets you write the producer/consumer shape very plainly.

public sealed class BackgroundTaskQueue
{
    private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
        Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
            new BoundedChannelOptions(100)
            {
                FullMode = BoundedChannelFullMode.Wait
            });

    public ValueTask EnqueueAsync(
        Func<CancellationToken, ValueTask> workItem,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(workItem);
        return _queue.Writer.WriteAsync(workItem, cancellationToken);
    }

    public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
        => _queue.Reader.ReadAsync(cancellationToken);
}

The BoundedChannelFullMode.Wait in this example means make the writer wait when the queue is full. That is backpressure.

In ASP.NET Core, consuming such a queue in combination with a BackgroundService is a clear shape. Compared with “true fire-and-forget,” this handles exceptions, shutdown, parallelism, and caps far more gracefully.

3.8. To Run at a Fixed Interval, PeriodicTimer

For fixed-interval async work, PeriodicTimer is highly readable.

public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));

    while (await timer.WaitForNextTickAsync(cancellationToken))
    {
        await RefreshCacheAsync(cancellationToken);
    }
}

What is good about this style:

  • The flow is easier to follow than callback-style timers
  • It can be written await-first
  • CancellationToken works naturally at shutdown

As caveats: PeriodicTimer is used on the assumption that you do not issue multiple concurrent WaitForNextTickAsync calls against one timer. And if the work takes longer than the period, that lateness has to be handled as a design matter. The timer will not parallelize on its own to catch up.

3.9. For Data Arriving Incrementally, IAsyncEnumerable<T>

Sometimes you want to process items as they arrive rather than accumulating everything into a List<T> first.

  • Reading a paginated API in sequence
  • Reading file lines bit by bit
  • Passing streaming results straight through

For these, IAsyncEnumerable<T> and await foreach are the natural fit.

public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
    await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
    {
        await ProcessUserAsync(user, cancellationToken);
    }
}

This shape suits cases where:

  • You do not want to wait until everything is ready
  • You want to process one item at a time
  • You do not want to hold everything in memory

Whether to return Task<List<T>> or IAsyncEnumerable<T> is easiest to decide by asking: will the results be used only once complete, or in arrival order?

3.10. For Asynchronous Disposal, await using

Types that need asynchronous work at disposal - flushing, closing connections - implement IAsyncDisposable. In that case use await using instead of using.

public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
    await using var stream = new FileStream(
        path,
        FileMode.Create,
        FileAccess.Write,
        FileShare.None,
        bufferSize: 81920,
        useAsync: true);

    await stream.WriteAsync(data, cancellationToken);
}

The points are:

  • For IAsyncDisposable, use await using
  • “Opening” being synchronous while “closing” is asynchronous is entirely normal

This helps when you want to avoid the mismatch of “the writes were async, but the final disposal was synchronous.”

3.11. For Mutual Exclusion Across await, SemaphoreSlim

In code that spans an await, there are situations where SemaphoreSlim replaces lock.

public sealed class CacheRefresher
{
    private readonly SemaphoreSlim _gate = new(1, 1);

    public async Task RefreshAsync(CancellationToken cancellationToken)
    {
        await _gate.WaitAsync(cancellationToken);
        try
        {
            await RefreshCoreAsync(cancellationToken);
        }
        finally
        {
            _gate.Release();
        }
    }

    private static Task RefreshCoreAsync(CancellationToken cancellationToken)
        => Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}

What matters are two things:

  • Enter with WaitAsync
  • Always call Release in finally

For “only one at a time” or “at most 3 concurrent calls to this external API,” SemaphoreSlim is thoroughly practical.

3.12. Write await Differently in UI / App Code / Libraries

ConfigureAwait(false) is not something to add whenever and wherever.

The broad split is this.

UI / app codeawait someAsync()Resume on the original contextGeneral-purpose libraryawait someAsync().ConfigureAwait(false)No assumption of returning to a specific context
  • UI / app code
    • Plain await is fine to start with
    • If UI updates or app-context-dependent work follows the await, it is more natural not to add ConfigureAwait(false)
  • ASP.NET Core app code
    • Plain await is usually sufficient
    • There is no need to force ConfigureAwait(false) as a blanket house rule
  • General-purpose library code
    • If it does not depend on UI or app models, ConfigureAwait(false) is a strong option

So remember:

  • App-side code: plain await
  • General-purpose libraries: consider ConfigureAwait(false)

and you will rarely run into trouble in practice.

4. Basic Writing Rules

4.1. Return Task / Task<T> First

For async method return types, think in this order first.

Return type First instinct
Task The default for async methods returning nothing
Task<T> The default for async methods returning a value
ValueTask / ValueTask<T> Choose only after measurement shows the need

ValueTask looks attractive, but it is not always better than Task. It is a struct, so it has copy costs, and its usage carries constraints.

The especially important point: a ValueTask is fundamentally meant to be awaited exactly once. It is not suited to being casually stored in a local and awaited repeatedly.

So for everyday application code, Task / Task<T> is plenty to start with.

Also, adding the Async suffix to method names keeps things clearer.

public Task SaveAsync(CancellationToken cancellationToken)
{
    return Task.CompletedTask;
}

public Task<int> CountAsync(CancellationToken cancellationToken)
{
    return Task.FromResult(_count);
}

As above, if there is nothing to await, it is more natural to return Task.CompletedTask or Task.FromResult than to force async onto the method.

4.2. async void Only for Event Handlers

The basic rule is to avoid async void outside event handlers.

The reason is simple:

  • The caller cannot await it
  • Completion cannot be waited on
  • Exception handling becomes difficult
  • It is hard to test

Event handlers are the one place that requires void, so use it only there.

private async void SaveButton_Click(object? sender, EventArgs e)
{
    try
    {
        await SaveAsync(_saveCancellation.Token);
        _statusLabel.Text = "Saved.";
    }
    catch (OperationCanceledException)
    {
        _statusLabel.Text = "Cancelled.";
    }
    catch (Exception ex)
    {
        MessageBox.Show(this, ex.Message, "Save error");
    }
}

In event handlers, the mindset that matters is: you write the part that catches exceptions inside and reports them back to the UI yourself.

4.3. Accept a CancellationToken and Pass It Downstream

For cancellable operations, accept a CancellationToken and pass it straight downstream.

public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
    using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

The common failure here is accepting a token at the top level but not passing it downstream. That tends to produce code that “looks cancellable, but does not actually stop midway.”

Also, timeouts mean different things depending on whether you want “a limit on the waiting only” or “the actual work stopped too.”

  • A limit on the waiting only: WaitAsync
  • Stopping the actual work too: CancellationTokenSource.CancelAfter plus token propagation

This distinction is a frequent source of later bugs, so deciding it up front keeps things stable.

4.4. Keep Async APIs Asynchronous All the Way Down

If you are using async / await, it is more natural to stay asynchronous all the way down wherever possible.

Rough replacement guide:

Tempting pattern Replace with
Task.Result / Task.Wait() await
Task.WaitAll() await Task.WhenAll(...)
Task.WaitAny() await Task.WhenAny(...)
Thread.Sleep(...) await Task.Delay(...)

Especially in UI and ASP.NET Core, mixing in synchronous waiting makes stalls hard to reason about.

Modern C# supports async Task Main(), so even in console apps there is little reason left to force things synchronous.

4.5. When Creating Tasks With LINQ, Materialize With ToArray / ToList

When combining Task.WhenAll or Task.WhenAny with LINQ, it is safer to materialize once with ToArray() or ToList().

Task<User>[] tasks = userIds
    .Select(id => _userRepository.GetAsync(id, cancellationToken))
    .ToArray();

User[] users = await Task.WhenAll(tasks);

The reason is LINQ’s lazy evaluation. Reading code under the assumption “everything has already started” when nothing has been enumerated yet is a quietly dangerous trap.

  • Waiting for everything together → ToArray()
  • Removing / replacing along the way → ToList()

That is an easy rule of thumb to keep.

5. Common Anti-Patterns

Anti-pattern Why it hurts First replacement
Task.Run(async () => await IoAsync()) Pointlessly re-dispatches an I/O wait await IoAsync()
Task.Result / Wait() Blocks the thread; prone to stalls await
Mixing Thread.Sleep() into an async flow Occupies the thread even while waiting Task.Delay()
async void on ordinary methods Cannot be awaited; exceptions hard to manage Task / Task<T>
Serial await where Task.WhenAll belongs Needlessly slow Start everything, then WhenAll
Firing huge volumes via WhenAll at once Load spikes Parallel.ForEachAsync / SemaphoreSlim
Trying to span an await with lock Does not fit the purpose SemaphoreSlim.WaitAsync
fire-and-forget via bare Task.Run Exceptions, shutdown, caps all vague Channel<T> / BackgroundService
Mechanically adding ConfigureAwait(false) to UI code UI updates after the await break easily Plain await
Making ValueTask the default Complexity rarely pays off Task first

Of this table, the three seen most often in real work are:

  1. Task.Run around I/O
  2. Serial await of genuinely independent operations
  3. Fire-and-forget with no lifetime management

Fixing just these three already improves code clarity dramatically.

6. A Code Review Checklist

In reviews of async/await code, work down this list from the top.

  • Can it be stated, in words, whether the work is I/O-bound or CPU-bound?
  • Any remaining Task.Result / Task.Wait() / Thread.Sleep()?
  • Any I/O waits wrapped in Task.Run?
  • Any independent operations needlessly awaited serially?
  • Conversely, any unbounded WhenAll over large volumes?
  • If a CancellationToken is accepted, is it properly passed downstream?
  • Any async void outside event handlers?
  • If fire-and-forget exists, is it decided who manages exceptions, shutdown, and caps?
  • If SemaphoreSlim is used, is Release inside finally?
  • If ValueTask is used, is there a measured reason, and is it awaited only once?
  • Does the presence/absence of ConfigureAwait(false) match the kind of code?
    • UI / app code: plain await
    • General-purpose libraries: consider ConfigureAwait(false)

This checklist is also handy for aligning review criteria across a team.

7. A Rough Guide to Choosing

What you want to do Reach for first
One HTTP / DB / file I/O await the async API directly
Heavy computation without freezing the UI Task.Run
A few independent async operations Task.WhenAll
Only the first result needed Task.WhenAny
Large volumes with a cap Parallel.ForEachAsync / SemaphoreSlim
Ordered background processing Channel<T>
Run at a fixed interval PeriodicTimer
Process a sequential stream IAsyncEnumerable<T> / await foreach
Mutual exclusion across await SemaphoreSlim
General-purpose library Consider ConfigureAwait(false)
Unsure about return type Task / Task<T> first

8. Conclusion

Best practice for async / await is less about memorizing many small techniques and more about the organizing principle of choosing the pattern to match the kind of work - that is what pays off in practice.

The order of examination goes roughly like this.

  1. Separate I/O waits from CPU computation
  2. For I/O, await the async API directly
  3. For CPU work, decide where it should run
  4. For multiple operations, choose WhenAll / WhenAny / capped parallelism
  5. To detach from the request lifetime, queue it rather than bare fire-and-forget
  6. Align the handling of return types, cancellation, exceptions, exclusion, and contexts

Because the async / await syntax itself is so concise, sloppy use obscures the intent. Conversely:

  • Treat I/O as I/O
  • Treat CPU as CPU
  • Treat background work as background work, with managed lifetimes

Just separating these three makes the code dramatically more readable.

9. References

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog