This overview reflects widely shared professional practices as of May 2026; verify critical details against current official guidance where applicable.
1. Blocking on Async Code: The Deadlock Trap
One of the most pervasive mistakes developers make is blocking on async code—calling methods like .Result or .Wait() on a Task from synchronous contexts. This pattern often leads to deadlocks in environments with a synchronization context, such as ASP.NET (classic), WPF, or WinForms. The root cause lies in how the async state machine captures and re-enters the original context. When you block, the calling thread waits, but the async continuation also needs that same thread to complete—a classic circular wait.
Consider a typical scenario: a desktop application fetches data from an API. The UI thread calls GetData().Result inside a button click handler. The async method awaits an HTTP request, yielding the thread. When the HTTP response arrives, the continuation tries to re-enter the captured SynchronizationContext (the UI thread), but that thread is blocked waiting for the Result. The application freezes indefinitely. This is not theoretical—many production outages trace back to exactly this pattern.
Understanding the Synchronization Context
The synchronization context determines where the continuation of an async method runs. In UI frameworks, it posts the continuation to the UI thread's message loop. In ASP.NET Classic (pre-Core), it uses AspNetSynchronizationContext to re-enter the request context. In ASP.NET Core and Console apps, the default context is the thread pool, which avoids this deadlock. But even in thread-pool contexts, blocking can cause thread pool starvation—a different form of stall.
To fix this, use the async-all-the-way-up pattern. Instead of var result = GetDataAsync().Result;, make the calling method async Task and use await. If you cannot (e.g., legacy code), use Task.Run(() => GetDataAsync()).Result to offload to a thread-pool thread that lacks the original context, but be aware this still blocks a thread. In ASP.NET Core, .Result may not deadlock, but it wastes a thread pool thread—reducing scalability. The best fix is to refactor the call chain to be async from top to bottom.
Another mitigation is to use ConfigureAwait(false) in library code to avoid capturing the context. However, this does not fix the blocking call itself—it only prevents the deadlock if the continuation does not need the original context. For UI apps, you must still avoid .Result on the UI thread. Some developers use Dispatcher.Invoke or Control.Invoke to marshal back, but that adds complexity. The cleanest approach: embrace async across all layers, even if it requires significant refactoring.
In practice, teams often discover this mistake during load testing when the application hangs after a few concurrent requests. The fix—replacing blocking calls with await—typically resolves the issue and improves throughput by orders of magnitude. Remember: async is not just about responsiveness; it's about scalability. Blocking a thread that could handle other work defeats the purpose.
2. Fire-and-Forget Without Handling Exceptions
Fire-and-forget—invoking an async method without awaiting it—is tempting for background tasks, logging, or notifications. However, unobserved exceptions in fire-and-forget tasks can silently crash your application or corrupt state. In .NET, a task that throws an unhandled exception becomes faulted. If the task is not observed (via await, .Result, .Wait(), or a continuation), the exception is rethrown on the finalizer thread when the task is garbage collected—causing an unhandled exception that terminates the process.
Consider a web API endpoint that sends a welcome email asynchronously: _ = SendEmailAsync(user);. If SendEmailAsync throws (e.g., SMTP server down), the exception remains unobserved. When the task is later collected, the process crashes. This is especially dangerous in long-running services where a single unobserved exception can bring down the entire application.
Safe Fire-and-Forget Patterns
To fire and forget safely, you must explicitly handle exceptions. One common pattern is to wrap the call in a separate method that catches all exceptions and logs them. For example:
_ = Task.Run(async () => { try { await SendEmailAsync(user); } catch (Exception ex) { Log.Error(ex, "Email send failed"); } });
This ensures the exception is observed within the task and does not go unhandled. However, Task.Run has its own pitfalls—it uses the thread pool, which may be inappropriate for short-lived tasks. Another approach is to use an async void event handler (only for top-level events, not library code). async void exceptions crash the process immediately because the method returns void, and there is no task to observe. Therefore, always wrap async void methods with try-catch.
For ASP.NET Core, consider using a background queue like IHostedService or Channel to process fire-and-forget work. This decouples the request lifecycle from the background task and provides a structured way to handle failures. For example, enqueue a message to a Channel and process it in a background loop with proper exception handling. This avoids thread pool starvation and ensures observability.
Another pattern is to use Task extensions that allow safe fire-and-forget, such as Task.Forget() from the AsyncEx library, which also logs exceptions. Ultimately, the safest approach is to never truly fire and forget—always have a way to observe errors, even if it's just logging. In high-stakes systems, consider using distributed tracing to correlate background work with the originating request.
Remember: unobserved task exceptions are a primary cause of silent data corruption and intermittent failures in production. By handling exceptions explicitly, you turn a crash into a logged event that can be investigated and resolved.
3. Sequential Awaits When Parallelism Is Needed
Writing await for each independent asynchronous operation sequentially is a common mistake that serializes work that could run concurrently. For example, fetching three independent API responses in sequence: var a = await GetAAsync(); var b = await GetBAsync(); var c = await GetCAsync(); Each subsequent call waits for the previous to complete, adding the sum of all latencies instead of the maximum latency. In high-latency environments (e.g., cloud APIs, database calls), this can multiply response times dramatically.
Imagine a dashboard that aggregates data from three microservices, each taking 200 ms. Sequential awaits yield 600 ms total. If you start all three tasks first and then await them, the total time drops to approximately 200 ms (the longest). This is especially impactful in user-facing applications where every millisecond matters. The fix is to start all independent tasks, store them, and then await them using Task.WhenAll.
Applying Task.WhenAll and Task.WhenAny
Task.WhenAll takes a collection of tasks and returns a single task that completes when all input tasks have completed. It does not start the tasks—it only awaits their completion. So you must start the tasks first (by calling the async method without awaiting) and then pass them to Task.WhenAll:
var taskA = GetAAsync(); var taskB = GetBAsync(); var taskC = GetCAsync(); await Task.WhenAll(taskA, taskB, taskC); var a = taskA.Result; var b = taskB.Result; var c = taskC.Result;
Note: accessing task.Result after Task.WhenAll is safe because the task is already completed. Alternatively, use await on each task after WhenAll. However, be cautious with WhenAll when tasks have side effects that interact—for example, if each task writes to the same file, you'll need synchronization. Also consider that WhenAll does not handle partial failures gracefully: if any task throws, the combined task faults, but other tasks continue running. You may need Task.WhenAll with exception handling or use Task.WhenAny for timeouts.
For scenarios where you need the first result (e.g., redundant data sources), use Task.WhenAny. But be aware of the "first wins" pattern: the remaining tasks continue running, so you should cancel them via a CancellationToken to avoid wasted resources. Another advanced pattern is to use Parallel.ForEachAsync for CPU-bound and I/O-bound mixed workloads, but that's beyond basic async.
A common mistake when using Task.WhenAll is to inadvertently capture the same variables in closures, leading to race conditions. For example, if you have a loop that starts tasks and each task modifies a shared collection, you need thread-safe access. Always evaluate whether your operations are truly independent before parallelizing. In summary, identify independent async operations, start them simultaneously, and await them together. This simple change can cut response times by 50–80% in many applications.
4. Ignoring Cancellation Tokens
Async methods that do not accept a CancellationToken are a missed opportunity for responsiveness and resource efficiency. Without cancellation, long-running operations—such as database queries, HTTP requests, or file reads—continue even after the user navigates away or the request is aborted. This wastes server resources, delays garbage collection, and can lead to thread pool starvation under load. In web applications, abandoned requests accumulate, degrading performance for other users.
Consider a search endpoint that queries an external API with a 30-second timeout. If the user closes the browser after 5 seconds, the server continues waiting for the API response. If many users do this, the server may exhaust its thread pool or connection pool, causing cascading failures. The fix is to propagate CancellationToken from the controller action down to all async calls.
Propagating Cancellation Through the Call Chain
ASP.NET Core automatically provides a HttpContext.RequestAborted cancellation token that is signaled when the client disconnects. Your controller actions should accept a CancellationToken parameter and pass it to every async method. For example:
public async Task Search(string query, CancellationToken ct) { var results = await _searchService.SearchAsync(query, ct); return Ok(results); }
Inside SearchAsync, pass the token to HttpClient methods, database queries (e.g., EF Core's ToListAsync(ct)), and any other I/O-bound calls. If you use Task.Delay, always pass a token. This allows the runtime to cancel operations promptly, freeing threads and connections.
A common mistake is to create a new CancellationTokenSource inside a method without linking it to an external token. If you need to implement timeouts, use CancellationTokenSource.CreateLinkedTokenSource to combine the external token with a timeout token. This ensures that both external cancellation and timeout work together. For example:
using var cts = CancellationTokenSource.CreateLinkedTokenSource(externalToken, new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token); await operationAsync(cts.Token);
Another pitfall is ignoring OperationCanceledException. When a task is cancelled, it throws OperationCanceledException (or TaskCanceledException for HTTP calls). Your code should catch this exception and treat it as a normal flow, not an error. In ASP.NET Core, the framework automatically converts it to a 499 (Client Closed Request) or 408 (Request Timeout) response. In UI apps, you may want to update the UI gracefully.
Finally, avoid swallowing cancellation exceptions without logging. If you catch OperationCanceledException to suppress it, ensure you log the cancellation for diagnostics. Also, be aware that not all async operations support cancellation—for example, some file I/O operations on older .NET versions. In such cases, you can still check the token periodically using token.ThrowIfCancellationRequested(). By consistently using cancellation tokens, you make your application more resilient, responsive, and scalable.
5. Using async void Methods Outside Event Handlers
async void methods are designed solely for top-level event handlers (e.g., button click handlers in UI frameworks) because they return void and cannot be awaited. However, many developers mistakenly use async void for other purposes, such as library methods, constructors, or property getters. This is dangerous because exceptions thrown in async void methods crash the process—they are not catchable by the caller. Additionally, the caller has no way to know when the method completes, leading to race conditions and unpredictable behavior.
Imagine a library method that initializes a cache: public async void InitializeCache() { await LoadDataAsync(); }. If LoadDataAsync throws, the exception is rethrown on the SynchronizationContext (often the thread pool) and brings down the process. Furthermore, any caller that calls InitializeCache() cannot await it, so they might try to read the cache before it's populated. This is a recipe for intermittent bugs.
Correct Usage: async Task vs. async void
For any method that is not a UI event handler, return Task or Task. This allows the caller to await the operation, handle exceptions, and compose with other tasks. If you need to call an async method from a constructor, consider a factory pattern or an asynchronous initialization method that returns a task. For example, instead of:
public class MyService { public MyService() { InitializeAsync(); } public async void InitializeAsync() { ... } }
Use:
public class MyService { private MyService() { } public static async Task CreateAsync() { var service = new MyService(); await service.InitializeAsync(); return service; } public async Task InitializeAsync() { ... } }
This pattern is known as async construction. For properties, avoid async at all—compute the value synchronously or use a lazy pattern with Lazy.
In UI applications, event handlers like button clicks must return async void because the event system expects a void return. Inside such handlers, you should wrap the entire body in a try-catch to avoid crashing the process. For example:
private async void OnButtonClick(object sender, EventArgs e) { try { await DoWorkAsync(); } catch (Exception ex) { // Log and show error to user } }
Never use async void in ASP.NET Core controllers or middleware—always return Task or Task. Similarly, avoid async void in lambda expressions passed to Task.Run or Parallel.ForEach; use async Task instead. The rule of thumb: if the method is not a UI event handler, it should return Task or Task. This simple discipline prevents process crashes and makes your code composable.
6. Not Using ConfigureAwait(false) in Library Code
When writing library code that is not tied to a specific synchronization context (e.g., class libraries, domain services), failing to use ConfigureAwait(false) on each await can cause performance issues and deadlocks. By default, await captures the current SynchronizationContext (or TaskScheduler) and attempts to marshal the continuation back to that context. In library code that may be called from UI or ASP.NET contexts, this unnecessary marshaling adds overhead and can reintroduce the deadlock risk discussed earlier.
For example, a library method that fetches data from a database:
public async Task GetCustomersAsync() { using var conn = new SqlConnection(connectionString); await conn.OpenAsync(); // captures context var result = await conn.QueryAsync(sql); // captures context return result.ToList(); }
If this method is called from a UI thread, each await will attempt to marshal back to the UI thread, even though the library method does not need to update UI. This adds overhead and can cause deadlocks if the calling code blocks on the result.
Best Practices for ConfigureAwait(false)
In library code that does not need to access the captured context (e.g., no UI, no request-specific data), use ConfigureAwait(false) on every await:
public async Task GetCustomersAsync() { using var conn = new SqlConnection(connectionString); await conn.OpenAsync().ConfigureAwait(false); var result = await conn.QueryAsync(sql).ConfigureAwait(false); return result.ToList(); }
This tells the runtime not to marshal the continuation back to the original context, reducing overhead and eliminating the deadlock risk. Note that ConfigureAwait(false) has no effect in environments without a custom synchronization context (e.g., ASP.NET Core, Console apps) because the default context is the thread pool, which does not require marshaling. However, it's still a good habit to include it in library code to ensure compatibility with all calling contexts.
A common misconception is that ConfigureAwait(false) should be used everywhere, including UI code. This is incorrect—in UI code, you need the continuation to run on the UI thread to update controls. Using ConfigureAwait(false) there would cause cross-thread access exceptions. So the rule: use ConfigureAwait(false) in library code that does not depend on the context; avoid it in UI code that must update the UI.
Another nuance: once you use ConfigureAwait(false) on one await, the rest of the method continues without a context. This means you can safely use it only for the first await in a method that has no context-dependent code before that point. If you need to access the context later (e.g., log to a request-specific logger), you should capture the context before the first await or use a different pattern. In practice, most library methods are context-agnostic, so ConfigureAwait(false) is safe and recommended.
By consistently applying ConfigureAwait(false) in library code, you improve performance, reduce context switching, and prevent deadlocks in mixed environments. It's a small change with significant impact.
7. Overusing async in CPU-Bound Code
Async-await is designed for I/O-bound operations (network, disk, database) where the CPU is idle while waiting. Using it for CPU-bound work—such as complex calculations, image processing, or data transformations—can actually hurt performance. When you await a CPU-bound task, you incur the overhead of the async state machine, context switches, and task scheduling without any I/O wait to offset it.
Consider a method that computes a Fibonacci number recursively. Wrapping it in Task.Run and awaiting it on the UI thread offloads the work to a thread pool thread, which keeps the UI responsive. However, the async overhead is minimal here, and the benefit (UI responsiveness) is real. The mistake is not using Task.Run for CPU-bound work that blocks the UI thread—that's a separate issue. The mistake is using async-await for CPU-bound work that is already offloaded to a background thread, or using async-await on a synchronous CPU-bound method that does not perform any I/O, expecting it to yield the thread.
Distinguishing I/O-Bound from CPU-Bound
The key is understanding what your code is waiting for. I/O-bound operations wait for external systems (network, disk, database). CPU-bound operations wait for the processor. Async-await helps with I/O-bound by freeing the thread while waiting. For CPU-bound work, you need a separate thread (using Task.Run, Parallel.For, or the ThreadPool) to avoid blocking the UI or request thread. Once you've offloaded to a background thread, you can use await to get the result, but the CPU-bound work itself should not be async.
For example, do not write:
public async Task CalculateAsync(int n) { return await Task.Run(() => { int result = 0; for (int i = 0; i
This is fine—it uses Task.Run to offload CPU work and then awaits the task to get the result. The mistake is to mark the method as async and use await on a synchronous CPU-bound method that does not call Task.Run internally:
public async Task CalculateAsync(int n) { return await Task.FromResult(ComputeHeavy(n)); }
Here, Task.FromResult creates an already completed task, so the method runs synchronously—the await adds overhead without any benefit. Worse, if the method is async but contains no await, the compiler warns, and the method runs synchronously. The fix is to either make it synchronous or use Task.Run to offload.
Another common pattern is to use Parallel.ForEach or PLINQ for CPU-bound loops, which use threads directly. Mixing these with async-await can lead to thread pool saturation. For mixed workloads (some I/O, some CPU), consider using Task.WhenAll with Task.Run for CPU parts and async methods for I/O parts. In summary, reserve async-await for I/O-bound operations; for CPU-bound work, use Task.Run or parallel programming constructs, and only use await to consume the result.
Frequently Asked Questions
This section addresses common questions developers have about async-await pitfalls and best practices.
What is the difference between async void and async Task?
async void is designed for top-level event handlers (e.g., button clicks in UI frameworks). The method returns void, so the caller cannot await it, and exceptions crash the process. async Task returns a task that can be awaited, allowing the caller to handle exceptions and know when the operation completes. Use async Task for all methods that are not event handlers.
How do I call an async method from a synchronous method?
The best approach is to make the calling method async as well (async all the way up). If you cannot, use Task.Run(() => AsyncMethod()).Result in a console app (avoid in UI or ASP.NET Classic due to deadlock), or use GetAwaiter().GetResult() which throws the original exception instead of AggregateException. However, these block a thread and can cause deadlocks. Prefer async throughout.
Should I use ConfigureAwait(false) everywhere?
No. Use ConfigureAwait(false) in library code that does not need to access the original synchronization context. In UI code (e.g., WPF, WinForms), do not use it because you need the continuation to run on the UI thread to update controls. In ASP.NET Core, the default context is the thread pool, so ConfigureAwait(false) has no effect, but it's still a good habit for library code.
What happens if I forget to await a task?
If you call an async method without awaiting it, the task runs concurrently, but any exceptions thrown are not observed until the task is garbage collected, which can crash the process. The method may also complete before you expect, leading to race conditions. Always await or explicitly handle the task (e.g., with a continuation or fire-and-forget with exception handling).
How do I cancel a running async operation?
Pass a CancellationToken to the async method and use it in all async calls. The token can be obtained from CancellationTokenSource or from HttpContext.RequestAborted in ASP.NET Core. When cancellation is requested, the operation should throw OperationCanceledException, which you can catch to handle gracefully.
Can I use async methods in constructors?
Constructors cannot be async. Use a factory pattern or an async initialization method that returns a task. The caller must await the initialization before using the object. Alternatively, use the IAsyncDisposable pattern or lazy initialization with Lazy.
What is the best way to run multiple async operations in parallel?
Start all tasks without awaiting, then use Task.WhenAll to await them all. This runs them concurrently, reducing total time to the longest operation. For CPU-bound work, use Parallel.ForEach or Task.Run with WhenAll. For I/O-bound, use async methods directly.
Conclusion and Next Steps
Async-await is a cornerstone of modern .NET development, but its power comes with pitfalls that can stall your application. We've covered seven common mistakes: blocking on async code, fire-and-forget without handling exceptions, sequential awaits when parallelism is possible, ignoring cancellation tokens, using async void outside event handlers, not using ConfigureAwait(false) in library code, and overusing async for CPU-bound work. Each of these can degrade performance, cause deadlocks, or lead to process crashes.
The good news is that the fixes are straightforward: adopt the async-all-the-way-up pattern, handle exceptions in fire-and-forget tasks, use Task.WhenAll for independent operations, propagate cancellation tokens, reserve async void for event handlers only, apply ConfigureAwait(false) in libraries, and distinguish I/O-bound from CPU-bound work. By internalizing these principles, you can write async code that is both responsive and reliable.
As a next step, review your existing codebase for these patterns. Use static analysis tools like Roslyn analyzers that flag async void methods or blocking calls. Consider enabling CA2007 (Consider calling ConfigureAwait on the awaited task) with a severity of suggestion in library projects. Also, invest in load testing to uncover hidden deadlocks or thread pool starvation. Regularly update your knowledge as .NET evolves—for example, ASP.NET Core's default no-synchronization-context model has reduced deadlock risks, but the principles remain vital.
Remember, async programming is not just about syntax; it's about understanding the underlying mechanics of the state machine and synchronization context. With careful design and consistent practices, you can avoid the stalls that plague many applications. Start by fixing one mistake at a time, and your app's performance and stability will improve measurably.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!