Skip to main content

Command Palette

Search for a command to run...

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

Updated
โ€ข10 min read
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 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, 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

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 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.

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 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 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 โ€” 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.

More from this blog

C

Coding Droplets

198 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.