A Practical Decision Table for C# async/await - Task.Run and ConfigureAwait
· Updated: · Go Komura · 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-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)
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
- 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
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/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 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.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.
flowchart TB
accTitle: Three questions that reduce hesitation
accDescr: Asking 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.
q1["What is it waiting on"] --> q2["Who owns the lifetime"]
q2 --> q3["Where is concurrency controlled"]
q3 --> less["Much less hesitation about how to write it"]
q1 -.-> avoid["Avoid 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.
flowchart TB
accTitle: The difference between I/O-bound and CPU-bound
accDescr: I/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.
io["I/O-bound (waiting for external completion)"] --> e1["The thread can be returned to other work while waiting"]
e1 --> fit["async/await is especially effective"]
cpu["CPU-bound (computation itself)"] --> e2["Which thread it runs on is the topic"]
e2 --> par["Deciding 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
awaitruns the continuation, it captures theSynchronizationContextin effect at the moment it started waiting and posts the continuation back there (if noSynchronizationContextis set, it checks whether a non-defaultTaskScheduleris in use) - WinForms / WPF have a
SynchronizationContextthat posts work back to the UI thread. That is why you can touch controls normally after anawait - ASP.NET Core has no
SynchronizationContext. There is nowhere to return to, so the continuation afterawaitsimply runs on an available thread pool thread ConfigureAwait(false)says the continuation may run without returning to that captured context
flowchart TB
accTitle: Where the continuation after await returns to
accDescr: await 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.
aw["await captures the context"] --> ui["The UI thread in WinForms or WPF"]
aw --> asp["ASP.NET Core has nowhere to return to"]
ui --> touch["Controls can be touched after the await"]
asp --> pool["The 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.
flowchart TB
accTitle: Asynchrony and parallelism are different things
accDescr: Asynchrony 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.
a["Asynchrony (how you wait)"] -.-> mix["Blurring them leads to overusing Task.Run"]
p["Parallelism (making progress simultaneously)"] -.-> mix
mix --> fork["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 |
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>"]
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.Runis unnecessary - Look for an async API first
- If you receive a token, pass it straight downstream
This is very much the standard path.
flowchart TB
accTitle: The basic shape for I/O waits
accDescr: For 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.
need["Waiting on HTTP, DB, or files"] --> find["Look for an async version of the API first"]
find --> aw["await it directly"]
wrap["Wrapping it in Task.Run"] -.-> bad["Just 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.
flowchart TB
accTitle: How Task.Run pays off in UI code
accDescr: Running 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.
heavy["Running a heavy calculation in a UI event"] -.-> freeze["The screen freezes"]
run["Task.Run moves it off the UI thread"] --> keep["The 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.Runis effective - ASP.NET Core request processing: wrapping in
Task.Runand immediatelyawait-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.Runpays 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.
flowchart TB
accTitle: Why Task.Run is avoided in ASP.NET Core
accDescr: Inserting 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.
tr["Inserting Task.Run into request processing"] --> r1["The total amount of computation is unchanged"]
tr --> r2["There is no special thread to free"]
r1 --> zero["It nets out to zero"]
r2 --> zero
zero --> cost["Switching 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.
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
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.WhenAnyreturns 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,
awaitthem, 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 thefinallywaits 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
cancellationTokentrips, every task ends inOperationCanceledException, so piling those intofailuresproduces anAggregateExceptionat the end and a user abort or timeout gets recorded and retried as an all-mirror outage. CallingThrowIfCancellationRequested()at the top of thecatchreturns cancellation asOperationCanceledException
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.
flowchart TB
accTitle: Confirming the winner with WhenAny before stopping the rest
accDescr: Because 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.
any["Get the first completion from WhenAny"] --> chk{"Did that task succeed"}
chk -->|"Success"| win["Cancel the rest and return"]
chk -->|"Failure"| next["Drop it from the candidates and record the failure"]
next --> rest{"Are any candidates left"}
rest -->|"Yes"| any
rest -->|"No"| agg["Throw 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.ForEachAsyncorSemaphoreSlim
Split it that way and you will not go far wrong.
flowchart TB
accTitle: Choosing how to gather parallel work by item count
accDescr: A 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.
q{"Is the item count large"}
q -->|"Only a handful"| all["Fire them all at once with Task.WhenAll"]
q -->|"Large"| limit["Decide a cap on parallelism"]
limit --> pfe["Parallel.ForEachAsync"]
limit --> sem["SemaphoreSlim 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.
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"]
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.”
flowchart TB
accTitle: Fire-and-forget versus a managed queue
accDescr: Throwing 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.
ff["Bare Task.Run, fired and forgotten"] -.-> vague["Exceptions, shutdown, and caps stay vague"]
ch["Queue it onto a Channel"] --> bs["A BackgroundService consumes it"]
bs --> mng["Exceptions, 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 CancellationTokenfits 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.
flowchart TB
accTitle: The periodic loop of PeriodicTimer
accDescr: The 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.
tick["Wait with WaitForNextTickAsync"] --> proc["Run the work with await"]
proc --> tick
stop["Stop via CancellationToken"] -.-> tick
proc -.-> warn["Lag 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.
flowchart TB
accTitle: Letting the use of the results decide the return type
accDescr: If 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.
q{"How are the results used"}
q -->|"Only once fully assembled"| list["Return the whole list in a Task"]
q -->|"As they arrive"| ae["Stream it with IAsyncEnumerable"]
ae --> each["Process one at a time with await foreach"]
ae -.-> mem["Nothing 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:
IAsyncDisposablemeansawait 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
Releasein afinally
For “only one at a time” or “at most three concurrent calls to an external API,” SemaphoreSlim is very practical.
flowchart TB
accTitle: The shape of mutual exclusion across an await
accDescr: In 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.
lk["lock cannot span an await"] -.-> alt["Use SemaphoreSlim instead"]
wait["Enter with WaitAsync"] --> crit["Do the work that contains awaits"]
crit --> rel["Always 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.
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"]
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
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 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.
flowchart TB
accTitle: Why async void is avoided and its one exception
accDescr: async 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.
av["An async void method"] --> p1["Cannot be awaited"]
av --> p2["Completion cannot be waited on"]
av --> p3["Exceptions and testing become difficult"]
ev["Event handlers are the exception"] -.-> duty["try/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.CancelAfterplus token propagation
This distinction easily turns into a defect later, so deciding it up front keeps things stable.
flowchart TB
accTitle: Propagating the CancellationToken
accDescr: Passing 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.
up["Accept a token at the top level"] --> pass["Pass it straight down to the APIs below"]
pass --> stop["The work really does stop partway"]
nopass["Accepting it without passing it on"] -.-> fake["Looks 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.
flowchart TB
accTitle: Replacements that keep synchronous waits out
accDescr: If 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.
chain["Stay asynchronous all the way down"] --> r1["Result and Wait become await"]
chain --> r2["Thread.Sleep becomes Task.Delay"]
mix["Synchronous waits mixed in"] -.-> clog["Stalls 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.
Task.Runon I/O- Serial await on work that is actually independent
- No lifetime management for fire-and-forget
Fixing just these three noticeably improves how readable the code is.
flowchart TB
accTitle: The three fixes seen most often in practice
accDescr: Wrapping 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.
a1["I/O wrapped in Task.Run"] --> f1["await the async API directly"]
a2["Serial await on independent work"] --> f2["Start everything, then WhenAll"]
a3["Unmanaged fire-and-forget lifetime"] --> f3["Move it to a Channel or BackgroundService"]
f1 --> better["Readability improves considerably"]
f2 --> better
f3 --> better
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
WhenAllwithout a cap? - If a
CancellationTokenis accepted, is it actually passed downstream? - Is there any
async voidoutside event handlers? - If
fire-and-forgetis used, has someone decided who manages exceptions, shutdown, and caps? - If
SemaphoreSlimis used, isReleaseinside afinally? - If
ValueTaskis 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)
- UI / app code: plain
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.
- Separate I/O waits from CPU computation
- For I/O,
awaitthe async API directly - For CPU computation, decide where it should run
- For multiple operations, choose
WhenAll/WhenAny/ a parallelism cap - To take work outside the request lifetime, queue it rather than using bare fire-and-forget
- 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
- The 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.
Practical Multithreading Best Practices: .NET Edition — What to Decide Before You Add More Threads
Keep .NET/C# threads from crashing or hanging. Ride on Task, cut shared mutable state, lock with discipline, stop with CancellationToken,...
Code Design for Business Systems — Deciding Product and Customer Codes, and Check Digits
A practical guide to deciding the code scheme for a business system, including product and customer codes. Covers a decision table for me...
What Is the .NET Generic Host? - The Foundation for DI, Configuration, and Logging
What the Generic Host does, seen through its relationship to DI, configuration, logging, IHostedService, and BackgroundService - and wher...
What Is .NET Native AOT? - How It Differs from JIT and Trimming
What Native AOT is, sorted out against JIT, ReadyToRun, self-contained, single-file, trimming, and source generators, plus the cases it f...
Choosing Between .NET's Three Timers - PeriodicTimer/Timer/DispatcherTimer
Which .NET timer should you use? PeriodicTimer for async loops, Timer for ThreadPool callbacks, DispatcherTimer for WPF UI, plus a decisi...
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.
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.