Are you still sprinkling async void
around your codebase like parmesan on pasta—assuming a little extra won’t hurt? Spoiler: that tiny keyword combo can set off silent exceptions, swallow stack traces, and turn your peaceful Friday night into a frantic bug‑hunt.
The Problem in a Nutshell
async await
is one of C#’s greatest productivity boosts, but the minute you pair async with a void return type, you disable its two biggest super‑powers:
- Exception propagation – the runtime can’t bubble errors back to the caller if there’s no
Task
to await. - Composability – without a returned
Task
, callers can’t await or chain the work, making flow control impossible.
async void
is basically fire‑and‑forget – great for launching fireworks, terrible for writing maintainable code.
Why Does async void
Even Exist?
async void
was introduced solely to support legacy event handler patterns:
private async void OnButtonClick(object? sender, RoutedEventArgs e)
{
await ViewModel.SaveAsync();
}
GUI frameworks (WinForms, WPF, Xamarin, MAUI) rely on void
‑returning delegates like EventHandler
. async void
allows us to plug await
logic into those handlers without refactoring the entire UI stack. Outside that niche, it’s a code‑smell.
5 Hidden Dangers of async void
Uncaught Exceptions Crash the App
public async void DoWork()
{
throw new InvalidOperationException("Boom!");
}
Because there’s no Task
, this exception skips your usual try
/await
/catch
fencing and lands straight in the SynchronizationContext UnhandledException pipeline. In ASP.NET Core, that can terminate the request; in a desktop app, it may crash outright.
Lost Error Context
An exception thrown inside an async void
method still surfaces eventually, but when it does, the stack trace has lost all of the awaited frames that matter to you.
public async void SaveCustomerAsync(Customer customer)
{
// Imagine this blows up with a SqlException
await _repository.SaveAsync(customer);
}
When the exception reaches the global handler you’ll only see SaveCustomerAsync and the runtime plumbing – nothing about who called the method or why. Compare that to the Task
‑returning variant:
public async Task SaveCustomerAsync(Customer customer) =>
await _repository.SaveAsync(customer);
// Call site
await _customerService.SaveCustomerAsync(cmd.Customer);
Because the caller awaits the task, the stack trace now includes SaveCustomerAsync, the service method, and the controller that initiated the operation – exactly the breadcrumbs you need for debugging.
Fire‑and‑Forget Race Conditions
Firing off work that looks synchronous is a recipe for subtle ordering bugs.
public async void CleanupAsync()
{
await _cache.RemoveAsync(_key);
_logger.LogInformation("Cache cleaned");
}
// Somewhere else
CleanupAsync(); // returns immediately
_responseCache.MarkComplete(); // runs **before** RemoveAsync finishes
The call to MarkComplete
moves a response object into an “immutable” state before the cache entry is gone. Minutes later another request fetches stale data and you spend an afternoon chasing a phantom race.
Refactor to return a task and await it (or explicitly run it in a background queue service that can supervise errors).
Testing Nightmares
Unit tests are naturally synchronous runners – they need to know when the System Under Test is done.
// Production code
static async void SendInvoiceEmailAsync(Order order) =>
await _mailer.SendAsync(order);
// Test
SendInvoiceEmailAsync(order);
Assert.True(_mailer.WasSent); // intermittent failure
The assertion runs before the email task completes. You end up sprinkling Thread.Sleep
or Task.Delay
in tests – none of which guarantee determinism.
static async Task SendInvoiceEmailAsync(Order order) =>
await _mailer.SendAsync(order);
// Test
await SendInvoiceEmailAsync(order); // deterministic
Assert.True(_mailer.WasSent);
Uncontrolled Concurrency
Every async void
method immediately posts its continuation to the current SynchronizationContext
. In UI frameworks that context is single‑threaded; saturate it with enough async void
calls and you block the message pump:
private async void OnKeyPress(object? sender, KeyEventArgs e)
{
await Task.Delay(50); // simulate work
_textbox.Text += e.KeyChar; // runs on UI thread
}
Rapid key presses queue hundreds of these continuations, freezing the interface for seconds. Return a Task
and throttle or await the handler, or offload the heavy work to Task.Run
so the UI remains responsive.
The Safe Alternatives
Scenario | Instead of… | Use… |
---|---|---|
Library / business logic | public async void Foo() | public async Task FooAsync() |
Fire‑and‑forget background work | async void | Task _ = Task.Run(() => …); plus error handling/logging |
Event handlers (UI) | Allowed but consider AsyncCommand pattern | IAsyncCommand wrapping Task ExecuteAsync() |
Streams | – | IAsyncEnumerable<T> + await foreach |
Refactor Example
Before:
// Bad
public async void ProcessOrder(int orderId)
{
await _repository.SaveAsync(orderId);
}
After:
// Good
public async Task ProcessOrderAsync(int orderId)
{
await _repository.SaveAsync(orderId);
}
// Somewhere else
await _orderService.ProcessOrderAsync(id);
Now callers can coordinate, retry, or compose the work safely.
How to Hunt Down async void
in Your Solution
- Roslyn analyzers – enable
AsyncFixer
orMicrosoft.CodeAnalysis.NetAnalyzers
ruleVSTHRD100
. - Visual Studio search –
Ctrl + Shift + F
forasync void
(case‑sensitive). - dotnet format / analyzers – treat
async void
outside events as errors. - Code review checklist – refuse PRs with non‑event‑handler
async void
.
Tip: add this to your .editorconfig
:
# Disallow async void except in event handlers
csharp_style_async_void_methods = false:error
FAQ: Common Questions About async void
async void
always bad?Only event handlers get a free pass. Everywhere else, return a Task
.
async void
lambdas in LINQ?Don’t. LINQ expects synchronous delegates. Wrap the async work in Task.Run
or switch to await foreach
over an IAsyncEnumerable
.
async void
?You can – the compiler allows it – but the runtime will ignore your errors and close the HTTP connection prematurely. Use Task<IActionResult>
instead.
async Task
have overhead?A tiny allocation; negligible compared to the debugging hours async void
costs. For high‑throughput scenarios use ValueTask
.
Conclusion: Avoid the Void, Embrace Task
async void
is a booby trap disguised as syntactic sugar – fine for click handlers, fatal everywhere else. Swap it for async Task
, wire up proper awaits, and your future self (and your QA team) will thank you. Ready to purge the void? Share your nastiest async void
horror story in the comments below!