Why You Should Avoid async void in C# (and What to Use Instead)

Avoid Async Void Methods in C#

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:

  1. Exception propagation – the runtime can’t bubble errors back to the caller if there’s no Task to await.
  2. 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

ScenarioInstead of…Use…
Library / business logicpublic async void Foo()public async Task FooAsync()
Fire‑and‑forget background workasync voidTask _ = Task.Run(() => …); plus error handling/logging
Event handlers (UI)Allowed but consider AsyncCommand patternIAsyncCommand wrapping Task ExecuteAsync()
StreamsIAsyncEnumerable<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

  1. Roslyn analyzers – enable AsyncFixer or Microsoft.CodeAnalysis.NetAnalyzers rule VSTHRD100.
  2. Visual Studio searchCtrl + Shift + F for async void (case‑sensitive).
  3. dotnet format / analyzers – treat async void outside events as errors.
  4. 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

Is async void always bad?

Only event handlers get a free pass. Everywhere else, return a Task.

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

Can I make an ASP.NET Core controller action 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.

Does 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!

Leave a Reply

Your email address will not be published. Required fields are marked *