AlgoMaster Logo

Async Best Practices

High Priority25 min readUpdated June 6, 2026

Async code in C# is easy to write and easy to write badly. The compiler accepts code that deadlocks under load, swallows exceptions, blocks the thread pool, or runs sequentially when it should run in parallel. This lesson pulls together the rules that prevent those bugs into one checklist, with a bad-versus-good example for each.

Async All the Way

Once a method becomes async, every caller up the chain should be async too. Mixing async and sync by calling .Result or .Wait() somewhere in the middle defeats the entire model. The thread that was supposed to be freed up sits there blocked, waiting for the async operation to finish, while the rest of your app starves for thread pool capacity. Worse, in some contexts it deadlocks, which we'll come back to in a moment.

What's wrong with this code?

GetProductName looks synchronous but it isn't really. It calls an async method, then immediately blocks on .Result. The calling thread is stuck waiting for the network round-trip, and on certain synchronization contexts (UI apps, older ASP.NET) this code deadlocks instead of finishing. Even on ASP.NET Core where it doesn't deadlock, it wastes a thread pool thread for every request.

Fix:

The whole chain is async. The thread is released back to the pool while the HTTP call is in flight. The rule is simple: if a method does any async work, the method is async, and so is everyone calling it.

Avoid async void

async void exists for exactly one reason: event handlers. UI frameworks like WinForms and WPF, and some web frameworks, define events with void-returning delegate signatures. To make an event handler async, you have no choice but to write async void. Outside of that one case, never use it.

What's wrong with this code?

The try/catch looks like it should catch the exception. It doesn't. An async void method has no Task to attach the exception to, so the exception is rethrown on the captured SynchronizationContext or on the thread pool, which usually crashes the process. The caller has no way to await the method, no way to know when it finished, and no way to observe failures.

Fix:

Returning Task instead of void lets the caller await the method and catch any exception it raises. The signature also changes from ProcessOrder to ProcessOrderAsync, following the standard naming convention.

The exception to the rule is genuine event handlers, where the framework dictates the signature:

When you're forced into async void, wrap the body in a try/catch so an unhandled exception doesn't take down the process.

Never Use .Result or .Wait()

.Result, .Wait(), and .GetAwaiter().GetResult() all do the same thing: they block the current thread until the task finishes. This is the synchronous wait we told you not to do, and it deserves its own section because the failure mode is severe. In any context with a synchronization context that runs continuations on a single thread (the UI thread in WinForms/WPF, the request context in classic ASP.NET), blocking the thread that owns the context means the continuation can never run, which means the task can never complete, which means the block never releases. The app freezes.

Lesson 07 (SynchronizationContext and ConfigureAwait) explains exactly why this deadlock happens. The short version: by default await captures the current SynchronizationContext and resumes on it. If that context's one thread is blocked waiting for the task, the resume can't get scheduled. Deadlock.

What's wrong with this code?

In a console app on .NET 8, this happens to work because there's no SynchronizationContext to deadlock against. The same code in a WPF event handler would freeze the UI thread permanently.

Fix:

Use await. Always. The only place .Result or .GetAwaiter().GetResult() is acceptable is inside a method that genuinely can't be async, such as the body of a static constructor or some rare interop scenarios, and even then only on a task you know is already completed.

ConfigureAwait(false) for Libraries

Lesson 07 covers ConfigureAwait(false) in detail. The rule for this lesson is a one-liner: in library code, append .ConfigureAwait(false) to every await unless you have a specific reason not to. In application code (your controllers, your view models, the top of your call stack), leave it off. A library doesn't know what context its callers run on; opting out of context capture avoids unnecessary context switches and prevents accidental deadlocks when a caller misuses .Result.

Library code (do this):

Application code (a controller, a view model) doesn't add ConfigureAwait(false). Inside a UI app or older ASP.NET, the final await resumes on the right context so your code can safely touch UI state or request-scoped services. ASP.NET Core has no SynchronizationContext, so ConfigureAwait(false) is a no-op there, but library authors don't know whose code will call them, so the library rule still applies.

Suffix Async Methods with Async

This is a convention, not a rule the compiler enforces, but the entire .NET ecosystem follows it. Any method that returns a Task or ValueTask ends with the suffix Async. It signals to readers (and to IntelliSense) that the method is awaitable.

What's wrong with this code?

Nothing about the signatures says these methods are awaitable. A reader scanning the class has to look at the return types to figure it out. In a long method chain, the difference between repo.GetCount() (returns a task you must await) and list.GetCount() (returns an int directly) becomes invisible.

Fix:

The suffix makes intent obvious. The BCL follows the convention for almost every async API (File.ReadAllTextAsync, HttpClient.GetAsync, DbContext.SaveChangesAsync), and your code should too. Two narrow exceptions: ASP.NET Core controller actions don't need the suffix because the framework treats them as endpoints rather than callable methods, and async test methods (xUnit [Fact], NUnit [Test]) typically skip the suffix because the test runner discovers them by attribute.

Task.Run Only for CPU-Bound Work

Lesson 03 (Task.Run and the Thread Pool) drew the line: Task.Run exists to push CPU-bound work onto a thread pool thread so it doesn't block the caller. It does nothing useful for IO-bound work. Wrapping HttpClient.GetAsync in Task.Run makes the code slower, not faster, because you're now consuming a thread pool thread to schedule another async operation that wasn't going to use one anyway.

What's wrong with this code?

GetStringAsync is already non-blocking. The HTTP request runs on the network stack, not on a thread. Task.Run adds a thread pool hop for no benefit, and on heavily loaded servers it starves the pool of threads that real CPU work needs.

Fix (IO-bound):

For genuinely CPU-bound work, Task.Run is appropriate. Computing a price recommendation, hashing a password, parsing a large in-memory document: those benefit from offloading to a thread pool thread so they don't block the caller.

Fix (CPU-bound):

The test is simple. If the method blocks the CPU, Task.Run helps. If it waits on the network, disk, or another async operation, Task.Run hurts.

Propagate CancellationToken

Lesson 06 (CancellationToken) covered how cancellation works. The best practice is short: if your method calls anything that accepts a CancellationToken, your method should accept one too and pass it through. Cancellation has to flow end-to-end, or it doesn't flow at all.

What's wrong with this code?

A caller who wants to cancel a request, perhaps because the user closed the page or a timeout fired, has no way in. The HTTP call runs to completion regardless. On a high-traffic server, this means cancelled requests still consume CPU, memory, and downstream API quota.

Fix:

Two changes: accept the token, pass it to every downstream async call that takes one. The default value makes callers who don't care about cancellation unaffected, while callers who do care can wire up cooperative cancellation end-to-end.

In ASP.NET Core, the framework gives controller actions a CancellationToken parameter automatically and ties it to the HTTP request lifetime. Use it.

Handle Fire-and-Forget Explicitly

A task you start but never await is a fire-and-forget operation, and the runtime won't warn you. If it throws, the exception lives on the unobserved task and either silently disappears or crashes the process at some indeterminate point. If you must run work in the background without awaiting it, do so deliberately, with explicit error handling.

What's wrong with this code?

The exception inside SendEmailAsync has nowhere to go. The compiler emits a warning (CS4014: "this call is not awaited"), but if you suppress it, the failure vanishes silently. On older .NET versions, unobserved task exceptions crashed the process when the task was finalized. On modern .NET they're logged through TaskScheduler.UnobservedTaskException and otherwise ignored.

Fix:

Two changes. The discard _ = documents that you're deliberately not awaiting the task. The wrapper SafeSendAsync catches every exception so nothing escapes unhandled. For production background work, prefer a real hosted service or queue instead of inline fire-and-forget, but when you must do it inline, this is the shape.

Don't Write Async Methods Without Await

The compiler will let you mark a method async even if it never awaits anything. When you do that, the method still generates a state machine, allocates a Task, and produces a CS1998 warning. The cost is small but real, and the signature lies about what the method does.

What's wrong with this code?

The compiler warns: "This async method lacks 'await' operators and will run synchronously." It still wraps the result in a Task<decimal> and emits state machine code. Callers think the method might run asynchronously and pay the cost of awaiting, when there's nothing to wait on.

Fix (option 1, the method has no async work):

If the method is genuinely synchronous, make it synchronous.

Fix (option 2, the method needs a Task return type for interface compatibility):

Drop the async keyword and return a completed task directly. Task.FromResult is allocation-cheap for common values and avoids the state machine entirely. Same signature, no warning, no overhead.

Async in LINQ Pitfalls

LINQ predates async by several years, and the standard operators don't understand Task. Pass an async lambda to Select and you get IEnumerable<Task<T>>, which is almost never what you wanted. The query also runs your async work eagerly when materialized, with no concurrency control.

What's wrong with this code?

Two problems stacked. First, Select doesn't await the tasks; the developer "fixed" that by adding .Result, which reintroduces the blocking-on-async issue. Second, the calls run one at a time as the sequence is iterated, with each .Result blocking until that single task finishes.

Fix:

Select is fine for starting the tasks (it produces IEnumerable<Task<bool>>), but you finish them with Task.WhenAll, which awaits all of them in parallel and returns an array of results. The method becomes async, the caller awaits it, and every stock check runs concurrently.

For a stream-style API with backpressure and async LINQ-like operators, look at System.Linq.Async (the package) and IAsyncEnumerable<T>, which lesson 09 covers.

ValueTask Only When Measured

Lesson 04 (Task vs ValueTask) explained when ValueTask<T> saves allocations: hot paths that often return synchronously, like a cache hit. The rule is to default to Task<T> and only switch to ValueTask<T> after you've profiled and seen the allocation cost is real. ValueTask has rules you don't have to think about with Task (no double-await, no concurrent awaits, no .Result before completion), and the savings only matter if the method is called millions of times.

What's the issue here?

Nothing is wrong, but the choice of ValueTask over Task adds rules without measurable benefit. The method awaits asynchronously every time, so there's no synchronous fast path to optimize. A Task<string> allocation per HTTP request is not the bottleneck of any real application.

Fix:

Use ValueTask<T> when (a) the method often completes synchronously, and (b) you've measured the allocation pressure and it matters. Otherwise, Task<T> is the right default.

Watch Lifetime Issues with using and async

The using statement disposes its target when control leaves the block. With async methods, "leaving the block" works correctly only when you await the result inside the block. Returning the task without awaiting can dispose the resource before the task completes.

What's wrong with this code?

The using block disposes reader as soon as ReadCatalogAsync returns. But ReadCatalogAsync returns before ReadToEndAsync finishes, because it's returning the task, not awaiting it. The caller awaits a task that's reading from a disposed StreamReader. The result is an ObjectDisposedException from the depths of the IO stack.

Fix:

async plus await keeps the using block alive until the async work finishes. Disposal only happens after ReadToEndAsync completes. The general rule: if a method holds a disposable resource and returns a task, the method should be async and await the task inside the block. Returning the task directly is faster (no state machine) but only safe when the method doesn't own any disposables.

Testing Async Code

Test methods can be async too. xUnit, NUnit, and MSTest all support async Task test methods on modern versions. Use them. Don't block on .Result in a test, for the same reason you don't block in production code: the test deadlocks in certain configurations, and even when it doesn't, it tests something subtly different than what your production code does.

What's wrong with this code?

The test method is synchronous, the call inside it isn't, and the bridge is .Result. On test runners that install a synchronization context, this deadlocks. Even when it works, it doesn't exercise the same await behavior production code uses.

Fix:

The test is async Task and awaits the call. Test frameworks understand this signature and wait for the returned task before declaring the test passed or failed. Async exceptions propagate to the test runner the same way synchronous ones do.

The Best Practices Cheat Sheet

The flow above maps the decisions you make for any async method: should it be async at all, is it library code, does it take a cancellation token, is the work IO-bound or CPU-bound, and how are you handling completion. Walking the chart catches most of the mistakes covered in this lesson.

Quiz

Async Best Practices Quiz

10 quizzes