# 7 Common Async/Await Mistakes in ASP.NET Core (And How to Fix Them)

Async/await in ASP.NET Core looks deceptively simple — until it doesn't. The same patterns that work flawlessly on a developer's machine will silently degrade throughput, introduce deadlocks, or drop entire requests in a production environment under load. Most of these failures share a common trait: they are invisible during local testing and catastrophic at scale. The full production-ready patterns — including error-handling wrappers, cancellation propagation, and IHostedService integration — are available on [Patreon](https://www.patreon.com/CodingDroplets) with annotated source code that maps directly to what enterprise .NET teams actually ship.

Understanding these mistakes well is also foundational to the [ASP.NET Core Web API: Zero to Production course](https://aspnetcoreapi.codingdroplets.com/), which covers async patterns inside a complete production codebase — alongside rate limiting, resilience, and background job design — so the context is always clear.

[![ASP.NET Core Web API: Zero to Production](https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg align="center")](https://aspnetcoreapi.codingdroplets.com/)

Below are the seven mistakes that appear most often in ASP.NET Core codebases, why they hurt, and how to fix each one.

## Mistake 1: Blocking on Async Code With `.Result` or `.Wait()`

This is the most dangerous async/await mistake in ASP.NET Core — and one of the most common. Calling `.Result` or `.Wait()` on a `Task` synchronously blocks the calling thread. In legacy ASP.NET, this pattern could cause a deadlock because the synchronization context was captured and the completion needed to resume on the same thread that was already blocked. ASP.NET Core does not have a synchronization context, which means the deadlock scenario is gone — but the damage is different and arguably worse: you are burning a ThreadPool thread for the entire duration of an I/O-bound wait.

When your API is under load, every request that calls `.Result` holds a thread hostage for the full duration of the database call, HTTP call, or file read it is waiting on. ThreadPool threads are finite. Once they are exhausted, incoming requests queue up and latency spikes. What looks like a working API in development becomes a progressively slow and eventually unresponsive service in production.

**The fix:** Propagate `async` and `await` all the way through the call chain. If a method calls an async API, that method must itself be async. There is no shortcut that is safe in a high-concurrency server environment.

If you have encountered ThreadPool exhaustion from this pattern in a running application, the [ThreadPool Starvation in ASP.NET Core: Production Root Cause and Fix](https://codingdroplets.com/aspnet-core-threadpool-starvation-production-fix) post walks through the diagnostic and recovery steps.

## Mistake 2: Using `async void` Outside of Event Handlers

`async void` is a special case in .NET: it exists specifically to support event handlers that need to be async. Outside of that use case, it is a trap. Methods marked `async void` cannot be awaited by callers, which means exceptions thrown inside them are not catchable at the call site. In ASP.NET Core, an unhandled exception inside an `async void` method will crash the process because there is no surrounding `Task` to catch the exception on.

The impact is severe: a silent fire-and-forget `async void` method that occasionally throws an exception — for example, when a dependency is temporarily unavailable — can take down the entire application with no meaningful stack trace logged to identify the cause.

**The fix:** Replace `async void` with `async Task` everywhere outside of event handlers. If you cannot await the returned `Task` at the call site because you are implementing an interface or callback that expects `void`, handle the exception explicitly inside the method body and log it. Never let an exception escape silently.

## Mistake 3: Fire-and-Forget Without a Proper Host

Fire-and-forget is a legitimate pattern — triggering a notification, logging an analytics event, enqueuing a background action. The mistake is implementing it as an unawaited `Task` directly inside a controller or middleware. When ASP.NET Core finishes processing a request, it disposes request-scoped services. A task started inside that request that is still running after the response is sent will find its `DbContext`, `HttpContext`, and scoped dependencies already disposed. The result is `ObjectDisposedException` or corrupted state.

Beyond the disposal problem, the request lifetime itself becomes a boundary: if the server is recycled or the application pool restarts while the background task is mid-execution, the work is silently lost.

**The fix:** Background work that outlives a request belongs in a proper host — specifically a `BackgroundService` or a durable job scheduler such as Hangfire. For tasks that need to outlive the request but are simple enough to avoid a full scheduler, the `IServiceScopeFactory` pattern inside a `BackgroundService` gives you a clean scope per unit of work.

## Mistake 4: Ignoring the `CancellationToken` in Async Methods

ASP.NET Core provides a `CancellationToken` through `HttpContext.RequestAborted` and — in controller actions and Minimal API handlers — directly as a method parameter. This token is signalled when the client disconnects before the response is sent. Ignoring it means the server continues processing the request, executing database queries, calling downstream APIs, and consuming memory for work that will never reach the client.

At low traffic volumes, this is wasteful but tolerable. Under load, with many clients disconnecting mid-request — as happens during slow page loads, timeouts, or mobile network drops — the server accumulates a backlog of zombie requests doing pointless work. This compounds with connection pool pressure, elevated database query counts, and resource contention.

**The fix:** Accept and propagate the `CancellationToken` in every async method that does I/O. Pass it to EF Core queries (`FindAsync(id, cancellationToken)`), `HttpClient` calls, and any other awaitable that accepts it. For the full picture of how `OperationCanceledException` interacts with middleware and global error handling, see [OperationCanceledException in ASP.NET Core: Causes and Fixes](https://codingdroplets.com/operationcanceledexception-aspnet-core-causes-and-fixes).

## Mistake 5: Misusing `ValueTask`

`ValueTask<T>` was introduced as a performance optimisation for high-frequency async paths where the operation completes synchronously on the hot path most of the time — the canonical example being a cache read that hits in memory. The allocation savings are real in that scenario. However, `ValueTask<T>` comes with constraints that `Task<T>` does not.

The most critical: a `ValueTask<T>` must be awaited exactly once. Storing it in a variable and awaiting it multiple times, passing it to `Task.WhenAll`, or awaiting it after the method returns are all undefined behaviour that can produce incorrect results or runtime exceptions. Developers who reach for `ValueTask<T>` indiscriminately — because they read it is faster — often apply it to methods that are called infrequently and do actual I/O on every call, where the allocation savings are negligible but the behavioural constraints remain.

**The fix:** Use `Task<T>` as the default return type for async methods. Reserve `ValueTask<T>` for hot-path methods where profiling confirms meaningful allocation pressure and the single-await constraint can be enforced by design. The Microsoft documentation on [when to use ValueTask vs Task](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.valuetask) is the authoritative reference for the trade-off.

## Mistake 6: Starting Multiple Async Operations Sequentially When They Are Independent

Consider two independent database reads that are both needed to build a response. Many developers write them sequentially with consecutive awaits — wait for the first, then start and wait for the second. This is correct in terms of result, but unnecessarily slow: the second I/O call does not start until the first completes, even though neither depends on the result of the other.

In a service handling hundreds of concurrent requests, this unnecessary serialisation adds latency to every affected request. When both operations take 50ms, sequential execution costs 100ms. Concurrent execution costs roughly 55ms.

**The fix:** Start both operations before awaiting either. Hold both returned `Task` references, then await `Task.WhenAll(task1, task2)`. The tasks execute concurrently, bounded by the available I/O threads and connection pool. The important caveat: if both operations use the same `DbContext` instance, this will throw because EF Core's `DbContext` is not thread-safe. Each concurrent operation must use its own scope.

## Mistake 7: Not Handling Exceptions in Parallel Async Workflows

When you use `Task.WhenAll`, exceptions from individual tasks are wrapped in an `AggregateException`. If you `await` the `Task.WhenAll` call and catch `Exception`, you only see the first exception. The others are silently swallowed. This means a parallel workflow where two out of three tasks fail will appear to fail once, with potentially misleading context, and the second failure goes unlogged.

This matters most in background processing and bulk operations, where losing exception context means missing actionable signals in your logs. The pattern of swallowed exceptions in fire-and-forget or `Task.WhenAll` workflows is a significant source of hard-to-diagnose intermittent failures in enterprise systems.

**The fix:** When awaiting `Task.WhenAll`, catch `Exception` but inspect the `AggregateException` from the `Task.Exception` property or iterate the individual task exception properties to log all failures. For production observability, ensure every parallel async workflow has explicit exception handling that captures and logs the full set of failures, not just the first.

* * *

## Which of These Should You Fix First?

### How do I know if my API has ThreadPool starvation from sync-over-async?

The clearest signal is latency spikes under moderate load when the API is otherwise healthy. If response times jump significantly with modest concurrency and CPU is low, ThreadPool starvation is a strong candidate. In .NET, you can check ThreadPool queue depth via `ThreadPool.GetAvailableThreads()` and compare against `ThreadPool.GetMaxThreads()`. A wide gap under load confirms the pattern.

### Is `ConfigureAwait(false)` still relevant in ASP.NET Core?

In ASP.NET Core specifically, there is no `SynchronizationContext`, so `ConfigureAwait(false)` does not prevent deadlocks in the same way it did in classic ASP.NET. However, it remains a best practice in library code and reusable components that may be consumed from contexts that do have a `SynchronizationContext` (WinForms, WPF, Blazor Server). For application-layer code in ASP.NET Core specifically, its impact on correctness is negligible.

### When is `async void` actually acceptable?

Only in two scenarios: event handlers that must match a `void`\-returning delegate signature, and overriding framework lifecycle hooks that are defined as `void` (rare). In all other cases, return `async Task`.

### What is the correct way to do fire-and-forget in ASP.NET Core?

The correct architecture depends on your durability requirement. For durable work that must not be lost on process restart, use a persistent job scheduler (Hangfire, Quartz.NET) or a message queue (Azure Service Bus, RabbitMQ). For best-effort in-process work that can tolerate loss on restart, use `System.Threading.Channels` with a `BackgroundService` consumer. Never use unawaited tasks directly in a controller.

### Can I use `Task.WhenAll` with EF Core queries?

Not with the same `DbContext` instance. EF Core's `DbContext` is not designed for concurrent use. To run EF Core queries concurrently, create a separate scope per query using `IServiceScopeFactory`, resolve a fresh `DbContext` from each scope, and then execute the queries in parallel. This is explicit by design — EF Core's thread-safety model makes concurrent misuse fail fast rather than produce unpredictable results.

### How does ignoring `CancellationToken` affect database connection pools?

A zombie request holding an open database connection continues to occupy that connection in the pool until the query completes. Under high disconnection rates, this exhausts the connection pool, causing subsequent requests to wait or fail. The [Cannot Resolve Scoped Service From Root Provider](https://codingdroplets.com/cannot-resolve-scoped-service-root-provider-aspnet-core) post covers related lifetime and resource management pitfalls that interact with the same connection pool.

### Should I always prefer `Task.WhenAll` over sequential awaits?

Only when the operations are genuinely independent and use isolated resources (separate `DbContext` instances, separate HTTP clients). Sequential awaits remain appropriate when operations have data dependencies, when they must execute in order for business reasons, or when parallel execution would exhaust a shared resource like a rate-limited API.

* * *

☕ Prefer a one-time tip? [Buy us a coffee](https://buymeacoffee.com/codingdroplets) — every bit helps keep the content coming!

* * *

These seven mistakes cover the most common failure modes seen in production ASP.NET Core services. Most can be prevented at design time with consistent patterns — propagate `async` all the way down, respect `CancellationToken`, use `BackgroundService` for fire-and-forget, and default to `Task<T>` unless profiling tells you otherwise. Getting these right does not require advanced knowledge — it requires knowing which shortcuts are not actually shortcuts in a server environment.
