Async and Await in C#: Writing Non-Blocking Code Correctly
Use async and await in C# correctly: what Task really is, ValueTask, ConfigureAwait, async void, cancellation tokens, and Task.WhenAll patterns.
The service was quick at 40 requests per second and dead at 400. Same code, same database, only load had changed. The profiler said the CPU was idle and the request queue was full. The thread pool said why: hundreds of threads parked in the same pattern, GetResult() on a task whose continuation needed a thread that did not exist. Three .Result calls, written by three careful developers in three different files, had quietly converted an async API into a blocking one under load.
Async and await in C# is the Task-based Asynchronous Pattern: methods marked async may await tasks. await is a suspension point that frees the current thread until the awaited work finishes. The official async programming guide covers the surface area; this page focuses on the execution model and the mistakes that matter in production.
First, the myth that produces most bad async code: async does not make code faster. A single async call has slightly more overhead than a sync one. What async buys is capacity: threads waiting on I/O are released to serve other requests, so throughput scales instead of collapsing when every thread is blocked. Async is a capacity tool, and every rule below follows from that.
The examples assume .NET SDK 10.0 with C# 14 and use small console or Web API shapes. Where a request path is needed, the Web API guide builds the full controller flow.
What async and await in C# actually do
Two keywords, one mechanism. async marks a method as able to suspend. await is the suspension point: when the awaited task is not yet complete, the method signs up a continuation, and control returns to the caller immediately. When the task completes, the continuation resumes, usually on a thread pool thread.
You can watch this happen without ever opening IL. Drop this in a console program:
Console.WriteLine($"Start on thread {Environment.CurrentManagedThreadId}");
await Task.Delay(100);
Console.WriteLine($"Resume on thread {Environment.CurrentManagedThreadId}");
Console.ReadLine();
The thread id before and after the await is often different. The second half of the method runs as a continuation, scheduled by the runtime. Under the hood the compiler rewrites an async method into a state machine that stores locals and jumps to the right step on each resumption. Decompilers such as sharplab show it clearly. For daily work you only need the observable contract. After an await, you are on some thread, not necessarily the one you started on.
Task: a promise, not a thread
Task represents work in progress; it is not the thread doing the work. Most tasks in web applications are I/O-bound: an HTTP call, a database query, a file read. During those awaits there is no thread at all devoted to your request, which is exactly why async scales. Distinguish the two shapes:
| Work is | Model | Tool | Awaiting releases |
|---|---|---|---|
| I/O-bound (network, disk, database) | Async call, no extra thread | await httpClient.GetAsync(...) |
The calling thread completely |
| CPU-bound (compute) | Offload to the pool | await Task.Run(() => Compute(...)) |
The calling thread, but a pool thread is now busy |
Task.Run is for CPU work you want off a request thread, never for wrapping a call that is already asynchronous. Wrapping Task.Run around httpClient.GetAsync adds a thread hop and changes nothing about the I/O. The Task-based Asynchronous Pattern document defines the naming and semantics the whole ecosystem follows, including why async methods end in Async by convention.
ValueTask: when the result is usually already there
Sometimes the answer is cached and available before the method is even called. Returning Task<T> from a synchronous fast path allocates a completed task each time. ValueTask<T> avoids that allocation by wrapping the value directly:
public ValueTask<TaskItem?> FindAsync(Guid id, CancellationToken ct)
{
return _cache.TryGetValue(id, out TaskItem? cached)
? new ValueTask<TaskItem?>(cached) // no allocation
: new ValueTask<TaskItem?>(LoadFromDbAsync(id, ct)); // real async
}
Two rules keep ValueTask safe: never await it twice, and never store it as a field for later. A ValueTask can only be consumed once; anything needing multiple awaits or storage should convert with .AsTask(). Use Task by default and reach for ValueTask where profiling shows allocation pressure on a hot path, which is the same discipline as any allocation optimization.
ConfigureAwait(false): a library concern
await tries to resume where it started. When a SynchronizationContext exists (desktop UI frameworks, legacy ASP.NET), that means resuming on the captured context. ConfigureAwait(false) opts out: resume on the thread pool, skip the context. On classic ASP.NET with its per-request context, blocking a thread that waits on the context’s continuation produced the famous deadlocks. ConfigureAwait(false) was the fix inside library code.
ASP.NET Core has no SynchronizationContext, so the deadlock does not exist there, and sprinkling ConfigureAwait(false) through application code buys nothing. The working rules: application code on .NET can skip it. General-purpose libraries add it to every await so they behave under every host. The ConfigureAwait FAQ on the .NET blog remains the definitive answer sheet for “should I use it here”.
async void: the crash-and-burn corner
async void exists for one audience: event handlers. Everywhere else it removes the caller’s ability to observe the task, and exceptions escape into the process instead of flowing to a handler:
WRONG:
public async void ProcessOrder(Order order)
{
await _repository.SaveAsync(order); // caller cannot await, cannot catch
}
RIGHT:
public async Task ProcessOrderAsync(Order order)
{
await _repository.SaveAsync(order);
}
When the wrong version throws, the exception surfaces on the thread pool and, with no handler, terminates the process. A web request that would have returned a 500 takes the whole service down. The only valid async void is a UI event handler where the signature is forced on you, and even there, wrap the body in try/catch.
Blocking: .Result and .Wait() starve thread pools
The blocking pair, .Result and .Wait(), converts an async method into a sync one by parking the calling thread until the task finishes. One occurrence is a slow call. A few hundred in a request path is an outage: each parked thread cannot serve other requests, the pool injects threads slowly, and queues back up. The scenario from this article’s opening was exactly that.
The fix is structural, not local: make the call chain async end to end. When an interface method must become async, change it, then fix its callers, and let the compiler walk you up the stack. The alternative people reach for, .ConfigureAwait(false).GetAwaiter().GetResult(), still blocks a thread; it just removes one deadlock scenario. There is no correct way to block on async under load; there is only not blocking.
// WRONG: blocks the request thread
var orders = GetOrdersAsync().Result;
// RIGHT: the request stays async all the way up
var orders = await GetOrdersAsync(ct);
Cancellation: a CancellationToken through everything
Async methods accept a CancellationToken, and serious APIs require it. Cancellation is cooperative: the token does not kill anything; the code holding it decides to stop when asked. Two habits make it real:
public async Task<List<TaskItem>> GetOpenTasksAsync(CancellationToken ct)
{
// the database call honors the token: a cancelled query stops mid-flight
return await _context.Tasks
.Where(t => !t.Done)
.ToListAsync(ct);
}
First, pass the token you were given; a token accepted and ignored is a lie in the signature. Second, give callers the chance to cancel: in ASP.NET Core, HttpContext.RequestAborted fires when a client disconnects, and plumbing it into queries means a closed browser stops burning database time. The EF Core querying guide shows where tokens land inside data access. On the operations side, the Kubernetes guide on graceful shutdown shows how SIGTERM becomes a cancellation the whole app observes.
Task.WhenAll: concurrent work, not sequential work
Awaiting calls one after another runs them sequentially even though each could run concurrently. Task.WhenAll starts them together and waits for all:
// sequential: each project waits for the previous call
foreach (var id in projectIds)
{
var project = await GetProjectAsync(id, ct);
results.Add(project);
}
// concurrent: all calls in flight at once
var tasks = projectIds.Select(id => GetProjectAsync(id, ct));
var results = await Task.WhenAll(tasks);
Two details matter in production. If one task throws, WhenAll throws that exception immediately and the other tasks keep running unobserved. So check each task (or log in a finally) when partial results matter. Concurrency needs limits: a thousand project ids means a thousand simultaneous database calls, which trades one problem for another. Batch the fan-out in this case. Task.WhenAny is the sibling for races and timeouts, returning the first task to finish.
How real systems do this
- Async all the way, enforced by review. Requests are async from controller to repository to driver. Any
.Resulton the path fails review the same way a nakedSELECT *would. - Every public async API takes a token. Service and repository signatures carry
CancellationTokenso callers have no excuse to skip it; controllers forwardHttpContext.RequestAbortedas shown in the Web API guide. - Fire-and-forget becomes a background job. Work that must outlive a request goes to a queue and a worker, never to a dropped task variable. The background jobs guide covers the worker pattern.
A performance issue I have seen in production is an “optimization” that wrapped an already-async database call in Task.Run inside a request handler. The intent was to make the call concurrent. The effect was one more pool thread occupied per request and worse throughput at peak. The profiler showed the thread count climbing with load while CPU sat idle. The takeaway: Task.Run multiplies threads for CPU work, it never improves I/O, and throughput problems should be diagnosed before they are optimized.
Decision framework: async, sync, or parallel
- Does the method await anything? If not, make it synchronous. The compiler warning CS1998 for
asyncmethods withoutawaitis pointing this out. - Is the work I/O-bound? Await the async API directly, pass the token, no
Task.Run. - Is the work CPU-bound and long? Move it out of the request path first (a queue and a worker); use
Task.Runonly for genuinely local, short bursts. - Are the calls independent?
Task.WhenAllruns them concurrently; sequential awaits give up capacity for no benefit. - Is this a shared library? Add
ConfigureAwait(false)to every await so behavior is safe under any host.
When NOT to make it async
- When there is nothing to await. A method that computes an answer and returns it stays synchronous.
asyncwith noawaitwastes a state machine and triggers warning CS1998. If a caller needs a task, returnTask.FromResult(result). - When the work must outlive the request. Fire-and-forget tasks die with the process and hide exceptions. Long work belongs in a queue with a worker, not in an unawaited task variable.
- When a small CPU-bound helper is on a hot path.
Task.Runhas scheduling cost, so wrapping a microsecond of computation trades latency for nothing. Keep it on the calling thread and measure if you doubt it.
Common mistakes
- Blocking with .Result or .Wait(). Under load this starves the thread pool and turns a capacity tool into an outage. Await instead, all the way up the call chain.
- async void outside event handlers. Exceptions inside it crash the process, and callers cannot observe completion; return
Task. - Dropping the CancellationToken. A signature that accepts a token but never passes it cannot stop canceled work, so shutdowns and client disconnects keep burning database and HTTP time.
- Task.Run around I/O. Wrapping an async call in
Task.Runadds a busy thread and no concurrency. Await the async API directly. - Unbounded Task.WhenAll. Fanning out thousands of calls at once moves the bottleneck to the downstream service; batch the fan-out.
- Awaiting a ValueTask twice.
ValueTaskis single-consumption; awaiting or storing it twice throwsInvalidOperationException. Convert with.AsTask()when you need to keep it.
If you work across stacks, the same ideas appear elsewhere. Compare this model with Python asyncio and its event loop, or with CompletableFuture in Java.
Key takeaways
- Async releases threads during waits; it does not make individual calls faster, and the goal is capacity under concurrency.
awaitis a suspension point: the method resumes as a continuation, possibly on a different thread pool thread.- Use
Taskby default andValueTaskfor profiled hot paths with synchronous fast results; aValueTaskis consumed exactly once. ConfigureAwait(false)belongs in shared libraries; application code in ASP.NET Core can skip it.- Every public async API takes a
CancellationToken, passes it through, and honors it in the awaited calls. - Independent calls run concurrently with
Task.WhenAll; blocking on tasks with.Resultor.Wait()starves the pool.
FAQ
Does async and await in C# make code faster?
No. Each awaited call carries slightly more overhead than a synchronous one. Async improves capacity: while a request waits on I/O, its thread serves other requests, so throughput scales under concurrency. A single user’s request is not faster, and CPU-bound code is not faster at all.
What is the difference between Task and ValueTask?
Task is a class representing in-progress work, safe to await any number of times and store anywhere. ValueTask is a struct that can wrap either a result or a task, avoiding the allocation when the result is often already available. But it can be consumed only once. Use Task by default and ValueTask only where profiling shows allocation pressure on a hot path.
What does ConfigureAwait(false) actually do?
It tells the await not to resume on the captured SynchronizationContext, running the continuation on the thread pool instead. In classic ASP.NET and desktop apps, blocking code that waits on a captured context can deadlock, so libraries use ConfigureAwait(false) to stay safe. ASP.NET Core has no synchronization context, so application code there usually does not need it.
Why is async void dangerous?
The caller cannot await it, cannot catch its exceptions, and cannot observe completion. When it throws, the exception propagates to the thread pool and can terminate the process. Its only valid use is event handlers whose signature forces it. Everywhere else, return Task or Task of T.
How do I cancel a running task in C#?
Create a CancellationTokenSource, pass its token into the async method, and call Cancel when you need to stop. The method observes the token at its awaited calls and stops cooperatively, typically by throwing OperationCanceledException. Cancellation only works if the code actually checks the token, so pass it into every layer, including database calls.
Async all the way, or not at all
The async model is consistent: await instead of blocking, tokens instead of hoping, concurrency instead of sequential awaits, and workers instead of fire-and-forget. Every violation eventually bills you at peak load, which is precisely when you cannot afford the invoice. Once these rules are habit, the ecosystem follows: EF Core, HTTP clients, and background workers all speak the same pattern. The next stop in this language track is nullable reference types, and every guide lives on the .NET series index. The rule of thumb to keep: await everything, block on nothing, and always carry the token.
Last updated on 18 October 2026
