C# async await mistakes that pass code review
It starts as a p99 problem. Requests that normally return in 80 ms take four seconds under load.
The thread count climbs while the CPU sits at 30 percent, apparently doing nothing. Someone
restarts the service and the problem clears, then it comes back at the next traffic peak. When
you finally read the code, nothing is wrong with it. Every method is async, every reviewer
approved it, and the demo worked flawlessly, because every mistake in this story is legal,
idiomatic-looking C#.
That is the defining feature of async bugs: they do not look like bugs. The compiler has nothing to say about them. What follows are the four that pass code review most reliably, and the one mental model that makes all of them obvious in advance.
What a thread actually does at an await
Start with the model, because it explains everything downstream. When execution reaches an
await and the awaited operation has not finished, the method does not wait. It registers a
continuation, returns to its caller, and the thread goes back to the thread pool to serve
someone else. The I/O proceeds with no thread attached at all: the network stack and the
operating system handle it. When the result arrives, a pool thread, usually a different one,
picks up the continuation and runs the rest of the method.
await
thread returns to the pool during I/O
3 ms work · 40 ms I/O on no thread · 3 ms continuation
.Result
thread blocked for the entire wait
one thread held hostage for all 46 ms
public async Task<Order> GetOrderAsync(int id, CancellationToken ct)
{
// the calling thread runs to this line, then leaves to serve other requests
var order = await _http.GetFromJsonAsync<Order>($"/orders/{id}", ct);
// a pool thread resumes here when the response arrives
return Enrich(order!);
}
This is why async servers scale: a few dozen pool threads can keep thousands of requests in
flight, because no thread is ever assigned to the job of waiting. It is also why the mistakes
below hurt. Each one quietly breaks the model: giving the exception no reader, giving the work no
owner, or making a thread stand in line anyway. An await is a bookmark, not a wait. Hold onto
that, and the rest of this article becomes predictable.
The compiler has no opinion about who observes a task or who owns its failure. Production has a strong one.
async void: exceptions with nowhere to go
An async Task method stores its exception inside the returned task, where it waits politely to
be observed by whoever awaits it. An async void method has no task. When it throws, the
exception has nowhere to live, so the runtime rethrows it directly onto the synchronization
context, and in ASP.NET Core, which has no synchronization context, that means the raw thread
pool. An unhandled exception on the thread pool does not fail one request. It terminates the
process.
// reads like a tidy helper; reviewers scroll past it
public async void RecordTelemetry(TelemetryBatch batch)
{
// one transient HTTP failure here can take the whole service down
await _client.SendAsync(batch);
}
There is exactly one legitimate use, and it is the reason the feature exists: event handlers,
whose signatures require void.
Stephen Cleary's canonical guidance
has said so since 2013, and it has not stopped being true. Everywhere else, return Task even
when nobody intends to await it, because a task someone could have awaited is recoverable and an
async void is not. The fix is a one-word change. Few bugs this severe are this cheap to remove.
Fire and forget that swallows failures
The discard is honest about one thing: you do not want to wait for this work.
_ = ProcessWebhookAsync(payload); // if this throws, nobody will ever know
What it hides is the failure path. Unlike async void, a faulted task that nobody awaits does
not crash anything. Since .NET 4.5, its exception is simply never observed. The webhook was not
processed, no log line was written, and the first person to find out is a customer, weeks later,
armed with a screenshot. You can register TaskScheduler.UnobservedTaskException as a last-ditch
net, and it is worth doing, but it fires late, on the finalizer thread, with no useful context
about which discard failed. It is a smoke alarm, not a design.
There is a second, sneakier failure in the same line. The task now outlives the request that
spawned it. If ProcessWebhookAsync reaches for anything scoped to the request, most commonly a
DbContext, that scope is disposed the moment the response completes, and the still-running task
starts throwing ObjectDisposedException into the same void as before.
The safe pattern is to give the work an owner. Queue it to something that lives longer than the request and is paid to care about failures:
public sealed class WebhookWorker(
Channel<WebhookPayload> queue,
IServiceScopeFactory scopes,
ILogger<WebhookWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var payload in queue.Reader.ReadAllAsync(ct))
{
try
{
await using var scope = scopes.CreateAsyncScope();
var handler = scope.ServiceProvider
.GetRequiredService<IWebhookHandler>();
await handler.ProcessAsync(payload, ct);
}
catch (Exception ex)
{
log.LogError(ex, "Webhook processing failed");
}
}
}
}
The controller writes to the channel and returns immediately, so the caller still gets the fire-and-forget latency. The difference is that the work now has an owner: the background service creates its own scope, observes every failure, and drains cleanly when the host shuts down. If a full worker feels heavy for your case, the minimum viable version is a helper that awaits the task and logs the exception. The unacceptable version is the bare discard.
Cancellation that stops at your method
Nearly every async API in the base libraries accepts a CancellationToken, and it is not
decoration. It is how work finds out that nobody wants its answer anymore. Cancellation in .NET
is cooperative: a token that is not passed along cannot stop anything.
public async Task<Report> BuildReportAsync(int customerId, CancellationToken ct)
{
var rows = await _db.Rows
.Where(r => r.CustomerId == customerId)
.ToListAsync(ct); // the query stops when the caller stops
var enriched = await EnrichAsync(rows, ct); // keep the token flowing down
return Render(enriched);
}
Here is why the firefighter should care. When a service is drowning, a meaningful slice of its load is often work whose caller gave up seconds ago: the user refreshed, or a client hit its timeout and retried. Without a token flowing through the call graph, every one of those abandoned requests runs to completion anyway, holding database connections and CPU that the live requests need. The retries pile on top, and the service is now doing double work at exactly the moment it can least afford single work.
ASP.NET Core hands you the token for free: declare a CancellationToken parameter on the action
and model binding wires it to HttpContext.RequestAborted. Accept it, pass it to everything
async you call, and cancellation becomes a feature you get by simply not dropping it. When you
need your own deadline on top, create a linked source from the caller's token with
CancellationTokenSource.CreateLinkedTokenSource, then call CancelAfter on it, so whichever
fires first stops the work.
Blocking on .Result and .Wait()
The fourth mistake gets one paragraph here because it deserves, and has, its own article. Calling
.Result or .Wait() converts async back into sync at the cost of an entire blocked thread per
pending operation, which is the rose-colored bar in the diagram above. Under load that becomes
thread pool starvation, and in environments with a synchronization context it can deadlock
outright. If your stalling service has .Result in a request path, start there. The full
treatment, including why the deadlock happens and what ConfigureAwait(false) does and does not
buy you, is in sync over async.
Making the invisible measurable
None of these mistakes shows up in a green test run. The async void crash needs a failure to
land at the wrong time. The swallowed webhook needs weeks to become a support ticket. The missing
token and the blocked thread need production-shaped concurrency before they cost anything at all.
Reading about them builds awareness; what builds the reflex is watching code you wrote get
charged for what it actually did.
Katabench charges that bill up front. Submissions run against
wall-clock and allocation budgets that treat time the way your users do,
and the practice tracks are full of code that looks reasonable and measures
badly, because that gap is precisely the skill. The engineers who stopped writing these four
mistakes did not memorize a checklist. They asked, once per await, who owns this thread, this
failure, and this cancellation, until the question became automatic. That takes reps, and reps
are cheaper on a practice platform than on your incident channel.