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

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

Revision history (1 updates, last updated Sep 1, 2026)

A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.

Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614461)
First published
Cite this article(DOI (registered archive): 10.5281/zenodo.21614460)

The DOIs below refer to previously archived versions and may not match the current text. Use this page’s URL to reference the current text.

Go Komura (2026). A Practical Decision Table for C# async/await - Task.Run and ConfigureAwait. KomuraSoft LLC. https://comcomponent.com/en/blog/2026/03/09/001-csharp-async-await-best-practices/

DOI (registered archive)
10.5281/zenodo.21614460
DOI (last registered version)
10.5281/zenodo.22217122

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)

How to Read This Article

This is a fairly long article, so here are the entry points by purpose.

Purpose Where to read
Just show me the decision table The table and diagram in 3.1. That is the heart of this article
I want to know how to write each pattern 3.2 onward. Each maps one-to-one to a row of the table in 3.1
I want to review my own code The anti-pattern table in 5.
I want to align review criteria The checklist in 6.
Just the conclusion 1.

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

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (26 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

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 separate 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.

Three questions that reduce hesitationAsking in turn what the operation is waiting on, who owns its lifetime, and where concurrency is controlled reduces hesitation about how to write async/await code.What is it waiting onWho owns the lifetimeWhere is concurrency controlledMuch less hesitation about how to write itAvoid reaching for Task.Run by default

Figure 1: Instead of picking a default, look first at the kind of wait, the lifetime, and the degree of concurrency.

2. Terms Used in This Article

2.1. Terms to Distinguish First

Separating these two up front prevents a lot of 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 is especially effective for I/O waits, where the thread can be returned to other work while waiting. CPU computation, by contrast, is not a wait but actual time spent computing, so the topics become which thread it runs on and how to decide the degree of parallelism.

The difference between I/O-bound and CPU-boundI/O-bound work is centered on waiting for external completion and can return the thread to other work during the await, while CPU-bound work is centered on computation itself so the topics become which thread it runs on and how much parallelism to use.I/O-bound (waiting for external completion)The thread can be returned to other work while waitingasync/await is especially effectiveCPU-bound (computation itself)Which thread it runs on is the topicDeciding the degree of parallelism is also the topic

Figure 2: These are the first two to separate. Whether waiting or computing dominates changes what you have to think about.

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 that holds “where the continuation after await runs.” See the note below for details
backpressure A mechanism that makes the writer wait when input is coming too fast, preventing unbounded growth
IHostedService The mechanism by which the .NET generic host calls StartAsync at startup and StopAsync at shutdown. It is the entry point for long-running work whose lifetime follows the app
BackgroundService An abstract class implementing IHostedService. Override a single ExecuteAsync(CancellationToken) and you have a resident loop. Register it with AddHostedService<T>() (3.7)

When you use Channel<T>, BackgroundService is where the consumer side belongs. Section 3.7 covers the shape where work is queued and a dedicated consumer processes it in order, and BackgroundService is what manages that consumer’s lifetime in step with app startup and shutdown.

A note on SynchronizationContext

The ConfigureAwait(false) discussion (3.12) ultimately comes down to understanding this one word.

  • When await runs the continuation, it captures the SynchronizationContext in effect at the moment it started waiting and posts the continuation back there (if no SynchronizationContext is set, it checks whether a non-default TaskScheduler is in use)
  • WinForms / WPF have a SynchronizationContext that posts work back to the UI thread. That is why you can touch controls normally after an await
  • ASP.NET Core has no SynchronizationContext. There is nowhere to return to, so the continuation after await simply runs on an available thread pool thread
  • ConfigureAwait(false) says the continuation may run without returning to that captured context
Where the continuation after await returns toawait captures the SynchronizationContext in effect when the wait began and posts the continuation back there, so in WinForms and WPF the continuation returns to the UI thread and can touch controls, while ASP.NET Core has nowhere to return to and the continuation runs on a thread pool thread.await captures the contextThe UI thread in WinForms or WPFASP.NET Core has nowhere to return toControls can be touched after the awaitThe continuation runs on the thread pool

Figure 3: The whole ConfigureAwait(false) discussion comes down to one point - where the continuation after an await goes.

From this follow the three conclusions of 3.12: it is more natural not to add it in UI code, it makes little difference either way in ASP.NET Core app code, and it is worth adding in general-purpose libraries that do not know which environment will run them. The most thorough background is the ConfigureAwait FAQ listed in 9. References.

One point matters especially: asynchrony and parallelism are different things.

  • Asynchrony: how you wait
  • Parallelism: how you make progress simultaneously

Once these two blur together, Task.Run starts looking useful everywhere. This is the first fork in the road.

Asynchrony and parallelism are different thingsAsynchrony is about how you wait and parallelism is about making progress simultaneously, and once the two blur together Task.Run starts looking useful everywhere, which makes this the first fork in the road.Asynchrony (how you wait)Blurring them leads to overusing Task.RunParallelism (making progress simultaneously)This is the first fork in the road

Figure 4: Asynchrony is about waiting, parallelism is about simultaneity. Lose that distinction and Task.Run gets overused.

3. The Decision Table to Look at First

3.1. The Big Picture

Start with this table and most of your direction is settled.

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;

Figure 5: The big picture of the decision. First separate I/O waits from CPU work; for multiple jobs, let the way you gather them pick the tool.

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 to avoid here is wrapping already-async I/O in Task.Run.

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

This just re-dispatches the I/O wait onto another thread - harder to follow, with nothing gained.

  • 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 very much the standard path.

The basic shape for I/O waitsFor HTTP, database, and file waits the basic move is to look for an async version of the API and await it directly, because wrapping already-async I/O in Task.Run merely re-dispatches the wait onto another thread with nothing gained.Waiting on HTTP, DB, or filesLook for an async version of the API firstawait it directlyWrapping it in Task.RunJust re-dispatching, with nothing gained

Figure 6: The basic move for an I/O wait is to await the async API directly. Avoid wrapping it in Task.Run.

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.

Running a heavy calculation inline in a UI event handler, for example, freezes the screen. Task.Run is the straightforward answer in that case.

How Task.Run pays off in UI codeRunning a heavy calculation inline in a UI event handler freezes the screen, so moving the CPU work off the UI thread with Task.Run keeps the screen responsive, which is the typical case where Task.Run pays off.Running a heavy calculation in a UI eventThe screen freezesTask.Run moves it off the UI threadThe screen stays responsive

Figure 7: Task.Run pays off when there is a special thread that has to be freed - the UI thread.

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);
}

What matters here, though, is where you are calling it from.

  • UI such as WinForms / WPF: there are situations where Task.Run is effective
  • ASP.NET Core request processing: wrapping in Task.Run and immediately await-ing it should generally be avoided
  • Worker / background processing: either process in place, or design the degree of parallelism

Slipping one Task.Run into ASP.NET Core request processing and awaiting it right away tends to add nothing but scheduling overhead.

This is easy to misread, so here are the reasons broken out. It is not that “Task.Run is pointless because the code already runs on the thread pool” (running on the thread pool is equally true of background work in a UI app). The two points that matter are:

  • Throughput does not increase. The total amount of CPU work is unchanged; it just moves to a different thread pool thread. The number of requests you can process concurrently does not go up
  • It does not free up a wait either. Task.Run pays off in a UI app because there is one special thread that has to be freed (the UI thread). The server side has no such thread. The original thread is indeed released, but another thread is occupied by the same computation for the same amount of time, so it nets out to zero

What remains is the cost of queueing and thread switching, plus one extra layer of ambiguity about which thread the code is running on. That is why we avoid it.

Why Task.Run is avoided in ASP.NET CoreInserting Task.Run into request processing does not change the total amount of computation and there is no special thread to free as there is in UI code, so it nets out to zero and all that remains is thread switching cost and reduced clarity.Inserting Task.Run into request processingThe total amount of computation is unchangedThere is no special thread to freeIt nets out to zeroSwitching cost and reduced readability remain

Figure 8: On the server side, Task.Run followed immediately by await buys neither throughput nor a freed-up wait.

So in ASP.NET Core, this is the sounder way to think about it.

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

That said, when the UI has to call an API that only exists in a synchronous version, Task.Run is sometimes used for the sake of UI responsiveness. But that is not “asynchronous I/O” - it is working around the problem by occupying one thread. On the server side, as in ASP.NET Core, this escape hatch generally does not scale.

3.4. For Multiple Independent Operations, Task.WhenAll

Code that waits on independent async operations one at a time, like this, comes up often.

// Independent operations, yet serialized
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 straightforward to start them all first and wait for them together at the end.

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

Figure 9: Start independent tasks first, then wait for them together with Task.WhenAll.

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);

    List<Task<byte[]>> pending = urls
        .Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
        .ToList();

    var failures = new List<Exception>();

    try
    {
        while (pending.Count > 0)
        {
            Task<byte[]> finished = await Task.WhenAny(pending);
            pending.Remove(finished);

            try
            {
                byte[] data = await finished;   // We only get past here on success
                cts.Cancel();                   // Stop the rest once a winner is settled
                return data;
            }
            catch (Exception ex)
            {
                // If the caller stopped us, that is not a mirror failure.
                // Letting it through here piles up the cancellation of every task as a
                // failure, ending in an AggregateException indistinguishable from an outage
                cancellationToken.ThrowIfCancellationRequested();

                // This mirror is no good. There is still hope in the rest, so continue
                failures.Add(ex);
            }
        }
    }
    finally
    {
        cts.Cancel();   // Stop any remaining downloads even when leaving via an exception

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

    throw new AggregateException("Failed to fetch from all mirrors.", failures);
}

What makes the ordering meaningful in this code is that cancellation is issued only after a winner is settled.

  • What Task.WhenAny returns is the first task to complete, not the first task to succeed. If the fastest mirror fails with a 404 or a dropped connection, that is what comes back as the “winner”
  • Cancelling before inspecting the result means stopping the remaining mirrors that are still alive yourself, and then rethrowing the failed winner’s exception. That is the worst way for this to break: the entire point of having multiple mirrors is erased
  • So instead, pull completed tasks out one at a time, await them, and cancel the rest only on success. On failure, drop that task from the candidates and wait for the next completion
  • Cancel() only issues a request; it does not wait for the other side to stop. That is why the finally waits for the remainder, observing cancellation and failure exceptions there. Skip it and you leave exceptions on those tasks that nobody ever looks at
  • When everything fails, throw the individual failures together. Throwing only the first exception erases which mirror failed and how
  • Caller cancellation alone is not counted as a failure; it is rethrown as is. When cancellationToken trips, every task ends in OperationCanceledException, so piling those into failures produces an AggregateException at the end and a user abort or timeout gets recorded and retried as an all-mirror outage. Calling ThrowIfCancellationRequested() at the top of the catch returns cancellation as OperationCanceledException

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 have to decide up front:

  • Do you want to cancel the rest?
  • Do you want to observe the exceptions?

Task.WhenAny is convenient, but it involves a bit more design than WhenAll. It is clearest to pick it only when “just the first one is enough” is genuinely true.

Confirming the winner with WhenAny before stopping the restBecause Task.WhenAny returns the first task to complete rather than the first to succeed, await completions one at a time and cancel the rest only on success, drop failures from the candidates and wait for the next completion, and throw the failures together if all of them fail.SuccessFailureYesNoGet the first completion from WhenAnyDid that task succeedCancel the rest and returnDrop it from the candidates and record the failureAre any candidates leftThrow the failures together

Figure 10: First to complete is not first to succeed. Confirm the winner’s result before stopping the rest.

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

Task.WhenAll runs every task it is given simultaneously. So when the item count is large, HTTP connections, DB connections, memory usage, and load on external services all spike at once.

In that situation 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:

  • The item count is large
  • Each item is processed independently
  • But you want to avoid firing everything at once

On the other hand, if you want freer control, there is also the SemaphoreSlim approach. For example, a rule like at most 4 concurrent calls to one particular external API.

In other words:

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

Split it that way and you will not go far wrong.

Choosing how to gather parallel work by item countA handful of independent operations can all run at once under Task.WhenAll, but with many items connections, memory, and external load all spike, so cap the degree of concurrency with Parallel.ForEachAsync or SemaphoreSlim.Only a handfulLargeIs the item count largeFire them all at once with Task.WhenAllDecide a cap on parallelismParallel.ForEachAsyncSemaphoreSlim for finer control

Figure 11: The dividing line is whether firing everything at once is acceptable. If the count is large, make the concurrency explicit.

3.7. To Process in Order, Channel<T>

Sometimes you want to decouple work from the caller - work that does not have to finish right now, but does have to get done. Sending mail, forwarding logs, post-processing a webhook, converting files.

Throwing that kind of work at a bare Task.Run leaves these questions unanswered:

  • 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

Figure 12: The flow through a bounded Channel. When the queue is full the writer waits, which is what applies backpressure.

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);
}

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 from a BackgroundService is the clearest shape. It handles exceptions, shutdown, parallelism, and caps far more gracefully than “true fire-and-forget.”

Fire-and-forget versus a managed queueThrowing work at a bare Task.Run leaves exceptions, shutdown, and how much to accept vague, whereas queueing to a Channel consumed in order by a BackgroundService makes exceptions, shutdown, and parallelism manageable.Bare Task.Run, fired and forgottenExceptions, shutdown, and caps stay vagueQueue it onto a ChannelA BackgroundService consumes itExceptions, shutdown, and parallelism are manageable

Figure 13: To decouple a lifetime from the caller, hand the work to a managed place rather than fire-and-forget.

3.8. To Run at a Fixed Interval, PeriodicTimer

For async work at a fixed interval, PeriodicTimer is very 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 a callback-based Timer
  • It can be written in terms of await
  • CancellationToken fits naturally for stopping it

One caution: PeriodicTimer assumes you never have multiple concurrent WaitForNextTickAsync calls against a single timer. And if the processing takes longer than the interval, that lag has to be handled as part of the design. The timer will not silently parallelize and catch up for you.

The periodic loop of PeriodicTimerThe loop waits for the next tick with WaitForNextTickAsync, runs the work with await, and returns to waiting, with stopping handled by a CancellationToken and lag from work longer than the interval handled as part of the design.Wait with WaitForNextTickAsyncRun the work with awaitStop via CancellationTokenLag from work longer than the interval is a design concern

Figure 14: Run one consumer per timer. The timer will not parallelize and catch up on its own.

3.9. For Data Arriving Incrementally, IAsyncEnumerable<T>

Rather than accumulating everything into a List<T> before returning, there are cases where you want to process items as they arrive.

  • Reading a paginated API page by page
  • Reading a file line by line
  • Passing streaming results straight through

IAsyncEnumerable<T> and await foreach are the natural fit here.

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 available
  • You want to process one item at a time
  • You do not want to hold everything in memory

Whether the return type should be Task<List<T>> or IAsyncEnumerable<T> is easiest to decide by asking whether the results are used only once they are all assembled, or as they arrive.

Letting the use of the results decide the return typeIf the results are used only once fully assembled, return the whole list wrapped in a Task, and if they are used one at a time as they arrive, return an IAsyncEnumerable and process it with await foreach.Only once fully assembledAs they arriveHow are the results usedReturn the whole list in a TaskStream it with IAsyncEnumerableProcess one at a time with await foreachNothing is held in memory in full

Figure 15: Assemble everything, or stream as it arrives. How the results are used decides the return type.

3.10. For Asynchronous Disposal, await using

Types that need asynchronous work at disposal time - flushing, closing a connection - implement IAsyncDisposable. In that case, use await using rather than 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:

  • IAsyncDisposable means await using
  • It is perfectly normal for “open” to be synchronous while “close” is asynchronous

This is what keeps you from the mismatch of “the writes are async but only the final disposal is synchronous.”

3.11. For Mutual Exclusion Across await, SemaphoreSlim

In code that spans an await, there are situations that call for SemaphoreSlim instead of 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 is these two things:

  • Enter with WaitAsync
  • Always call Release in a finally

For “only one at a time” or “at most three concurrent calls to an external API,” SemaphoreSlim is very practical.

The shape of mutual exclusion across an awaitIn code that spans an await, use SemaphoreSlim instead of lock, entering with WaitAsync, doing the work that contains awaits, and always calling Release in a finally.lock cannot span an awaitUse SemaphoreSlim insteadEnter with WaitAsyncDo the work that contains awaitsAlways Release in a finally

Figure 16: WaitAsync at the entrance, Release in a finally at the exit. Keeping that pair intact is the whole point.

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

Figure 17: App-side code uses plain await and resumes on the original context; general-purpose libraries should consider ConfigureAwait(false).

  • 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 the return type of an async method, 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 convenient, but it is not always better than Task. It is a struct, so it has copy costs, and its usage carries constraints.

The point that matters most is that ValueTask is fundamentally meant to be awaited exactly once. It is not suited to being casually held in a local variable and awaited repeatedly.

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

It is also clearer to add the Async suffix to method names.

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

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

As above, when there is nothing to await, returning Task.CompletedTask or Task.FromResult is more straightforward than forcing async onto the method.

4.2. async void Only for Event Handlers

As a rule, avoid async void outside event handlers.

The reasons are simple:

  • The caller cannot await it
  • You cannot wait for completion
  • Exception handling becomes difficult
  • It is hard to test

Event handlers are the one place void is required by the signature, so that is the only place it is used.

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

In an event handler, it matters to take responsibility yourself for catching exceptions inside and surfacing them to the UI.

Why async void is avoided and its one exceptionasync void cannot be awaited by the caller, cannot be waited on for completion, and makes exception handling and testing difficult, so it is avoided in ordinary methods and used only in event handlers whose signature requires void, where a try/catch surfaces exceptions to the UI.An async void methodCannot be awaitedCompletion cannot be waited onExceptions and testing become difficultEvent handlers are the exceptiontry/catch and surface to the UI

Figure 18: Ordinary methods return Task / Task. The only place async void is allowed is an event handler.

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);
}

A common pattern here is accepting the token at the top level but never passing it downstream. That tends to produce code that “looks cancellable but does not actually stop partway.”

Timeouts also mean different things depending on whether you want to cap only the wait, or stop the actual work as well.

  • Cap only the wait: WaitAsync
  • Stop the actual work too: CancellationTokenSource.CancelAfter plus token propagation

This distinction easily turns into a defect later, so deciding it up front keeps things stable.

Propagating the CancellationTokenPassing a CancellationToken received at the top level straight down to the APIs below makes the work stop partway, whereas accepting it without passing it on produces code that looks cancellable but never stops.Accept a token at the top levelPass it straight down to the APIs belowThe work really does stop partwayAccepting it without passing it onLooks like it stops, but it does not

Figure 19: Once you accept a token, pass it all the way down. A missed hand-off produces cancellation that never cancels.

4.4. Keep Async APIs Asynchronous All the Way Down

If you are going to use async / await, it is more straightforward to stay asynchronous all the way down as far as possible.

Here are the usual replacements.

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

In UI and ASP.NET Core especially, mixing in synchronous waiting makes stalls much harder to reason about.

Modern C# supports async Task Main(), so even console apps have far less reason to force everything back to synchronous.

Replacements that keep synchronous waits outIf you use async/await, stay asynchronous all the way down by replacing Result and Wait with await and Thread.Sleep with Task.Delay, because mixing in synchronous waiting makes stalls harder to reason about.Stay asynchronous all the way downResult and Wait become awaitThread.Sleep becomes Task.DelaySynchronous waits mixed inStalls become harder to reason about

Figure 20: Once you commit to async, do not drop back into synchronous waiting partway - stay asynchronous to the end.

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 with ToArray() or ToList() first.

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

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

The reason is that LINQ is lazily evaluated. Reading code on the assumption that “everything has already started” when in fact nothing has been enumerated yet is quietly dangerous.

  • To wait for everything together, ToArray()
  • To remove or swap items along the way, ToList()

Remembering it that way makes the choice easy.

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 everything in this table, these three show up most often in practice.

  1. Task.Run on I/O
  2. Serial await on work that is actually independent
  3. No lifetime management for fire-and-forget

Fixing just these three noticeably improves how readable the code is.

The three fixes seen most often in practiceWrapping I/O in Task.Run, awaiting genuinely independent work serially, and leaving fire-and-forget lifetimes unmanaged are the three seen most often in practice, and replacing each of them makes the code noticeably clearer.I/O wrapped in Task.Runawait the async API directlySerial await on independent workStart everything, then WhenAllUnmanaged fire-and-forget lifetimeMove it to a Channel or BackgroundServiceReadability improves considerably

Figure 21: Of everything in the anti-pattern table, these three are the most effective to fix first.

6. A Code Review Checklist

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

  • Can you state in words, first thing, whether the work is I/O-bound or CPU-bound?
  • Are there any Task.Result / Task.Wait() / Thread.Sleep() left?
  • Is an I/O wait wrapped in Task.Run?
  • Is independent work being awaited serially without need?
  • Conversely, are huge volumes being run through WhenAll without a cap?
  • If a CancellationToken is accepted, is it actually passed downstream?
  • Is there any async void outside event handlers?
  • If fire-and-forget is used, has someone decided who manages exceptions, shutdown, and caps?
  • If SemaphoreSlim is used, is Release inside a finally?
  • If ValueTask is used, is there a measured reason, and is it awaited exactly once?
  • Does the presence or absence of ConfigureAwait(false) match the kind of code it is in?
    • 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

The list of what to choose when is consolidated in the decision table in 3.1. Rather than repeat the same table here, it is easier to find by going back to it when you need it, so this section has no table.

  • To see what to reach for first by situation, see the decision table in 3.1
  • To see how to write each pattern, see 3.2 through 3.12 (they map to the rows of the table in 3.1)

There is exactly one decision that is not in the 3.1 table: the return type. That is a matter of method design rather than situation, so it is collected in 4.1. In short: choose Task / Task<T> first, and reach for ValueTask only after measurement shows the need.

8. Conclusion

Best practices for async / await work better in practice as the single idea of choosing the shape that matches the kind of work than as a pile of individual techniques.

The order to look at things is roughly this.

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

Because the syntax of async / await is itself so concise, careless use makes the underlying policy hard to see. Conversely:

  • Treat I/O as I/O
  • Treat CPU work as CPU work
  • Manage background work’s lifetime as background work

Separating just these three makes the code considerably easier to read.

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.

Frequently Asked Questions

Common questions about the topic of this article.

When should I use Task.Run in C#?
Task.Run pays off when you want to move CPU computation off the current thread. Running a heavy calculation inline in a WinForms or WPF UI event handler, for example, freezes the screen, so moving it off the UI thread with Task.Run is the straightforward choice. ASP.NET Core request processing, on the other hand, already runs on the ThreadPool, so inserting a Task.Run and awaiting it immediately tends to add nothing but scheduling overhead, and is generally avoided. Long-running work, or work you want to decouple from the request lifetime, is better handed off to a queue or a HostedService.
Is it wrong to wrap I/O work in await Task.Run()?
For I/O waits such as HTTP, databases, and file reads and writes, the basic move is to await the async version of the API directly; there is no need to wrap it in Task.Run. Wrapping already-async I/O in Task.Run merely re-dispatches the wait onto another thread, which makes the code harder to follow for no gain. Task.Run does get used when the UI calls an API that only has a synchronous version, to keep the app responsive, but that is not asynchronous I/O - it works around the problem by occupying one thread - so it is an escape hatch that does not scale well on the server side.
Where should I put ConfigureAwait(false)?
In UI and application-side 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 is usually fine with plain await as well, and there is no need to force it as a blanket house rule. Where ConfigureAwait(false) is a strong option is general-purpose library code that does not depend on UI or app models. Remember it as plain await on the app side, consider ConfigureAwait(false) in general-purpose libraries, and you will rarely run into trouble in practice.
Why should async void be avoided outside event handlers?
Because the caller cannot await it, cannot wait for completion, exception handling becomes difficult, and it is hard to test. Ordinary methods should return Task or Task<T> as the default. Event handlers are the one place it belongs, since their signature requires void, and there it matters to take responsibility yourself for catching exceptions inside the handler with try/catch and surfacing them to the UI.

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