Mert Özen Further With Every Line
Backend Asynchronous Programming in C# — #09

C# async void and Fire-and-Forget: Lost Exceptions

Mert Özen Nov 7, 2026 8 min 2 views
C# async void and Fire-and-Forget: Lost Exceptions

Why an exception from an async void method crashes the process without ever reaching try/catch, and how async Task fixes it. Using async void correctly in event handlers, hidden async void with List.ForEach, the silently lost failure of an un-awaited task and UnobservedTaskException, the CS4014 warning, a safe Forget extension that logs the failure, and the scope trap in ASP.NET Core.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

We wrote a method that saves an order, but its return type is void, not Task. We put the call inside a try/catch, then wait half a second and print "Done". The method throws an exception after 100 ms. Does the catch block run, and does "Done" appear on the screen?

try
{
    SaveOrderAsync(42);
    Console.WriteLine("SaveOrderAsync called");
}
catch (Exception ex)
{
    Console.WriteLine($"Caught: {ex.Message}");
}

await Task.Delay(500);
Console.WriteLine("Done");

static async void SaveOrderAsync(int orderId)
{
    await Task.Delay(100); // as if writing to the database
    throw new InvalidOperationException($"Order {orderId} could not be saved");
}

async void Hands the Exception to Nobody

The output:

SaveOrderAsync called
Unhandled exception. System.InvalidOperationException: Order 42 could not be saved

A stack trace of a few lines follows, and the process exits with exit code 134; on macOS and Linux this means the process was terminated with an abort. The catch did not run, and "Done" was never printed. The application crashed.

The reason is the return type. When an async Task method throws, the exception is put inside that task and delivered to whoever awaits the task. An async void method has no task to deliver it in. When the method reached its first await, it returned to the caller; the caller left the try block and went on its way. When the exception was thrown 100 ms later, nobody was waiting for it. So the runtime rethrows it on the SynchronizationContext that was active when the method started, or on the thread pool if there was none. The Task.<>c.<ThrowAsync> and ThreadPoolWorkQueue.Dispatch lines in the stack trace show exactly that. And an unhandled exception on the thread pool terminates the process.

You can compare it to sending a parcel. async Task is a shipment with a tracking number: if the parcel gets lost, you know, and you can ask about it with the tracking number. async void is a parcel dropped off without a tracking number. If something happens on the way, there is nobody to notify; in the worst case the warehouse shuts down.

The Same Code with async Task

We change one word and await the call:

static async Task SaveOrderAsync(int orderId)
{
    await Task.Delay(100);
    throw new InvalidOperationException($"Order {orderId} could not be saved");
}
Caught: Order 42 could not be saved
Done

The exception travelled inside the task, await rethrew it, and the catch caught it. The application carried on. Besides, you cannot even wait for an async void method: await SaveOrderAsync(42) does not compile, because there is nothing to wait for. You cannot find out when it finished, and you cannot check its result in a test.

The rule is simple: if you are writing an async method, its return type should be Task or Task<T>. There is one exception for async void.

The Only Legitimate Place for async void: Event Handlers

Event handler signatures return void; that is how the EventHandler delegate is defined and you cannot change it. That is why button clicks in UI applications are written as async void. The rule here is to wrap the whole body of the method in a try/catch:

async void OnSaveClicked(object? sender, int orderId)
{
    try
    {
        await SaveOrderAsync(orderId);
        Console.WriteLine($"Order {orderId} saved");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"[error] save failed: {ex.Message}"); // in a real app, log it and show it to the user
    }
}
[error] save failed: Order 42 could not be saved
Still running

The handler caught the exception itself and the application kept running. Keep such a handler thin: it should contain only the try/catch and an await call to an async Task method that does the real work. The logic itself stays in the testable, Task-returning method. In UI frameworks the handler runs on the UI thread's context, so an unhandled exception goes to that context and ends up in the framework's global error event. Still, catching an escaped exception there is a far worse option than showing the user a meaningful message.

Hidden async void: Lambdas

You can produce async void without ever writing it. Look at this code that ships orders one by one:

List<int> orderIds = new() { 1, 2, 3 };

orderIds.ForEach(async id =>
{
    await Task.Delay(100);
    Console.WriteLine($"Order {id} shipped");
});
Console.WriteLine("All orders shipped");

await Task.Delay(300);
All orders shipped
Order 2 shipped
Order 1 shipped
Order 3 shipped

"All orders shipped" was printed first, and the orders after it, in a jumbled order. List<T>.ForEach takes an Action<T>, a delegate that returns void. The compiler turns the async lambda you give it into an async void method. ForEach calls each lambda, the lambda returns at its first await, and ForEach moves on to the next one. Nothing is awaited. Had one of the lambdas thrown, the process would have crashed just like in the first example. The compiler gives no warning here either.

When you pass an async lambda to a parameter, look at the parameter's type. If it is Func<Task> or Func<T, Task>, you are fine; if it is Action or Action<T>, you have a hidden async void in your hands. Parallel.ForEach carries the same trap. The fix is await inside a plain foreach, or, if you want to start them all at once, await Task.WhenAll(orderIds.Select(ShipAsync)).

Fire-and-Forget: The Task You Do Not Wait For

Some work you really do not want to wait for. The order is saved; let the confirmation email go out in the background so the user does not wait for the SMTP server. The method is async Task, but we deliberately do not await the result:

TaskScheduler.UnobservedTaskException += (sender, e) =>
{
    Console.WriteLine($"UnobservedTaskException: {e.Exception.InnerException?.Message}");
};

_ = SendConfirmationEmailAsync(42); // deliberately not awaited
await Task.Delay(300);
Console.WriteLine("Email task is long gone, nobody noticed");

GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("After GC");

static async Task SendConfirmationEmailAsync(int orderId)
{
    await Task.Delay(100);
    throw new InvalidOperationException($"SMTP server rejected the email for order {orderId}");
}
Email task is long gone, nobody noticed
UnobservedTaskException: SMTP server rejected the email for order 42
After GC

This time the process did not crash, because the exception was stored inside the task. But nobody looked at it. The email was not sent, and for 300 ms nobody knew. The exception only reached the TaskScheduler.UnobservedTaskException event when the garbage collector collected that task. In the example we called GC.Collect() ourselves; in a real application it is unknown when that happens, maybe minutes later, maybe never. Since .NET Framework 4.5 this event does not crash the process either; the default behavior is to swallow the exception silently.

So fire-and-forget with async Task does not crash, but it fails silently. The user gets no confirmation email, and there is not a single line in the log.

The Compiler's Warning: CS4014

If you leave a Task-returning call un-awaited inside an async method, the compiler warns you:

await PlaceOrderAsync(42);

async Task PlaceOrderAsync(int orderId)
{
    await Task.Delay(50);
    SendConfirmationEmailAsync(orderId);
    Console.WriteLine($"Order {orderId} placed");
}

static async Task SendConfirmationEmailAsync(int orderId)
{
    await Task.Delay(100);
}
Program.cs(6,5): warning CS4014: Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
Order 42 placed

Take this warning seriously; most of the time it points at an await that was genuinely forgotten. If you are not waiting on purpose, write _ = SendConfirmationEmailAsync(orderId); to state your intent explicitly, and the warning goes away. But the discard only silences the warning; as you saw in the previous example, the exception still has no owner.

Safe Fire-and-Forget

To at least log the failure of a task you do not wait for, a small extension method is enough:

static class TaskExtensions
{
    // async void on purpose: no exception escapes
    public static async void Forget(this Task task, string operation)
    {
        try
        {
            await task.ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[error] {operation} failed: {ex.Message}"); // ILogger in a real app
        }
    }
}
SendConfirmationEmailAsync(42).Forget("confirmation email");
Console.WriteLine("Order 42 placed");

await Task.Delay(300);
Order 42 placed
[error] confirmation email failed: SMTP server rejected the email for order 42

We used async void on purpose here, and we are not breaking this post's rule: the whole body of the method is inside a try/catch, so no exception can escape. ConfigureAwait(false) is there because there is no need to return to a UI context. The failure is no longer lost; it shows up in the log. In real code you would pass an ILogger instead of using Console.WriteLine.

This helper makes the failure visible, but it does not guarantee that the work gets done. If the process shuts down in the meantime, the email still does not go out. Work that must not be lost needs a queue; let's talk about that in the next section.

Fire-and-Forget in ASP.NET Core

In web applications fire-and-forget has one more trap: the request scope. When you write _ = SendConfirmationEmailAsync(order) in a controller, the response returns right away and ASP.NET Core closes the request's dependency injection scope. Scoped services from that scope, such as a DbContext, are disposed. The moment the task still running in the background touches that DbContext, it gets an ObjectDisposedException, and that exception, as you saw above, is silently lost too. For the details of the scoped lifetime, see the dependency injection post of the ASP.NET Core series.

The right pattern is to drop the work into a queue instead of starting it inside the request. The request writes a message to the queue and returns immediately. In the background, a BackgroundService that lives for the whole application reads the queue and opens its own scope for each job with IServiceScopeFactory. That way the scoped services live correctly, and the host can wait for this service to finish when the application shuts down. System.Threading.Channels is a great fit for an in-process queue; we will see it in the Channels post of this series. For work that must not be lost even if the process crashes, you need a durable queue (a database table, RabbitMQ, Azure Service Bus and the like).

Common Pitfalls

Writing async void anywhere other than an event handler. The exception crashes the process, and the method cannot be awaited or tested.

Making an event handler async void without a try/catch. A single failure in the handler can bring down the whole application.

Passing an async lambda to List<T>.ForEach, Parallel.ForEach or any API that takes an Action. The compiler does not warn you, and you end up with a hidden async void.

Silencing CS4014 with _ = and considering the job done. The warning goes away, but still nobody observes the exception.

Using services from the request's scope in a background task in ASP.NET Core. When the response returns, the scope closes and the service is disposed.

A Small Experiment

★☆☆ Run the first example and look at the exit code (echo $?). Then make the method async Task and leave the call un-awaited. How do the output and the exit code change?

★★☆ Rewrite the shipping example with Task.WhenAll instead of ForEach. Make sure the "All orders shipped" line comes last and that an exception thrown by one order can be caught.

★★★ Add an Action<Exception> parameter to the Forget extension and report OperationCanceledException not as an error but as a separate informational message.

So far we have always used Task and Task<T>. Every Task<T> is an object and takes up space on the heap; if the result usually comes straight from a cache, that cost is unnecessary. In the next post we look at ValueTask, when to choose it, and the rules you must follow when using it, with BenchmarkDotNet measurements.