A Practical Decision Table for C# async/await - Task.Run and ConfigureAwait
· Go Komura · 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-forgetand losing track of exceptions and shutdown timing - Sprinkling
ConfigureAwait(false)everywhere indiscriminately - Choosing
ValueTaskpurely 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
- The Conclusion First (In One Line)
- Terms Used in This Article
- 2.1. Terms to Distinguish First
- 2.2. Frequently Appearing Terms
- 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
- 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
- Common Anti-Patterns
- A Code Review Checklist
- A Rough Guide to Choosing
- Conclusion
- References
1. The Conclusion First (In One Line)
async/awaitis 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.Runcan help in UI code, but in ASP.NET Core request processing, wrapping work inTask.Runand immediately awaiting it should generally be avoided - For multiple independent operations, consider
Task.WhenAllbefore awaiting them serially - With many items, do not fire everything at once via
Task.WhenAll- decide a cap on parallelism fire-and-forgetlooks 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>. ChooseValueTaskonly after measurement shows the need ConfigureAwait(false)is a strong option in general-purpose library code, but plainawaitis fine in UI and application-side codeasync voidis 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:
- What is this operation actually waiting on?
- Who owns this operation’s lifetime?
- 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 |
flowchart TD
start["The work you want to do"] --> q1{"Waiting on external I/O?"}
q1 -- "Yes" --> p1["await the async API directly"]
q1 -- "No" --> q2{"Heavy CPU computation?"}
q2 -- "Yes" --> q3{"Where does it run?"}
q3 -- "UI event / desktop" --> p2["Consider Task.Run"]
q3 -- "ASP.NET Core request" --> p3["Do not wrap in Task.Run<br/>If needed, move to a worker or queue"]
q3 -- "Worker / background" --> p4["Run in place or<br/>make the parallelism explicit"]
q2 -- "No" --> q4{"Handling multiple jobs?"}
q4 -- "Wait for all to finish" --> p5["Task.WhenAll"]
q4 -- "Use whichever finishes first" --> p6["Task.WhenAny"]
q4 -- "Many items" --> p7["Parallel.ForEachAsync<br/>or SemaphoreSlim"]
q4 -- "Process in order" --> p8["Channel<T>"]
q4 -- "Fixed interval" --> p9["PeriodicTimer"]
q4 -- "Sequential stream" --> p10["IAsyncEnumerable<T>"]
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.Runis 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.Runhelps - ASP.NET Core request processing: generally avoid
Task.Runimmediately followed byawait - 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.
sequenceDiagram
participant Caller as Caller
participant T1 as Task 1
participant T2 as Task 2
participant T3 as Task 3
Caller->>T1: Start
Caller->>T2: Start
Caller->>T3: Start
Caller->>Caller: await Task.WhenAll(...)
T1-->>Caller: Done
T2-->>Caller: Done
T3-->>Caller: Done
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.ForEachAsyncorSemaphoreSlim
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.
flowchart LR
p["producer"] --> w["WriteAsync"]
w --> q{"Is there room in the queue?"}
q -- "Yes" --> c["Enters the Channel"]
q -- "No" --> b["Waits until there is room"]
c --> d["consumer ReadAsync"]
d --> e["await 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 CancellationTokenworks 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, useawait 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
Releaseinfinally
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.
flowchart LR
a["UI / app code"] --> b["await someAsync()"]
b --> c["Resume on the original context"]
d["General-purpose library"] --> e["await someAsync().ConfigureAwait(false)"]
e --> f["No assumption of returning to a specific context"]
- UI / app code
- Plain
awaitis fine to start with - If UI updates or app-context-dependent work follows the await, it is more natural not to add
ConfigureAwait(false)
- Plain
- ASP.NET Core app code
- Plain
awaitis usually sufficient - There is no need to force
ConfigureAwait(false)as a blanket house rule
- Plain
- General-purpose library code
- If it does not depend on UI or app models,
ConfigureAwait(false)is a strong option
- If it does not depend on UI or app models,
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.CancelAfterplus 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:
Task.Runaround I/O- Serial await of genuinely independent operations
- 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
WhenAllover large volumes? - If a
CancellationTokenis accepted, is it properly passed downstream? - Any
async voidoutside event handlers? - If fire-and-forget exists, is it decided who manages exceptions, shutdown, and caps?
- If
SemaphoreSlimis used, isReleaseinsidefinally? - If
ValueTaskis 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)
- UI / app code: plain
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.
- Separate I/O waits from CPU computation
- For I/O,
awaitthe async API directly - For CPU work, decide where it should run
- For multiple operations, choose
WhenAll/WhenAny/ capped parallelism - To detach from the request lifetime, queue it rather than bare fire-and-forget
- 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
- Complete sample code for this article (library, demo, unit tests) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-async-await-best-practices
- Asynchronous programming scenarios - C#
- Asynchronous programming with async and await
- Task-based Asynchronous Pattern (TAP) in .NET
- ConfigureAwait FAQ
- Parallel.ForEachAsync Method
- Task.WaitAsync Method
- System.Threading.Channels library
- Create a Queue Service
- Background tasks with hosted services in ASP.NET Core
- Generate and consume async streams
- Implement a DisposeAsync method
- ValueTask Struct
- CA2012: Use ValueTasks correctly
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
What Is the .NET Generic Host? - The Foundation for DI, Configuration, and Logging
The role of the Generic Host, explained through its relationship with DI, configuration, logging, IHostedService, and BackgroundService -...
What Is .NET Native AOT? - How It Differs from JIT and Trimming
What Native AOT is, explained through its differences from JIT, ReadyToRun, self-contained, single-file, trimming, and source generators ...
Choosing Between .NET's Three Timers - PeriodicTimer/Timer/DispatcherTimer
The differences between PeriodicTimer / System.Threading.Timer / DispatcherTimer, and how to choose between them for async processing, Th...
Why Use the .NET Generic Host and BackgroundService in Desktop Apps
How to use the Generic Host and BackgroundService to organize startup, periodic processing, shutdown, logging, configuration, and DI in W...
WPF/WinForms async and the UI Thread on One Sheet
Sorting out where execution resumes after await in WPF / WinForms, plus Dispatcher / Invoke, ConfigureAwait(false), and where .Result / ....
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
UI Threading & Timers
Topic page for WPF / WinForms UI threading, async flow, Dispatcher usage, and timer decisions.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
In Windows apps involving UI, background processing, and I/O, knowing when to use which async/await pattern translates directly into implementation quality.
Technical Consulting & Design Review
If you want to sort out Task.Run and ConfigureAwait decisions together with responsibility partitioning, that leads into technical consulting and design review.
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.
Public links