Stop using async void and fire-and-forget
async void can crash your process, and _ = Task.Run(...) in a web request quietly loses errors and uses disposed services. Here's what to do instead.
Two async patterns cause more production incidents than they should. Both look harmless and both compile without a warning.
async void
public async void SendWelcomeEmail(User user)
{
await emailClient.SendAsync(user.Email, "Welcome!");
}
A method that returns void can't be awaited, so the caller has no idea when it finishes or whether it worked. Worse, an exception thrown inside an async void method has nowhere to go. It's raised on the thread pool, and in a .NET app that terminates the process. One failed email takes down the whole service.
async void exists for exactly one case: event handlers, whose signature you can't change. Everywhere else, return Task:
public async Task SendWelcomeEmailAsync(User user, CancellationToken ct)
{
await emailClient.SendAsync(user.Email, "Welcome!", ct);
}
Watch for lambdas too. Passing an async lambda to a parameter of type Action silently creates an async void.
Fire-and-forget in a request
The other pattern looks like a performance improvement:
app.MapPost("/users", async (CreateUser command, AppDb db, IEmailService email) =>
{
var user = await CreateUserAsync(command, db);
_ = Task.Run(() => email.SendWelcomeAsync(user)); // don't make the caller wait
return Results.Created($"/users/{user.Id}", user);
});
Three things go wrong:
- Errors disappear. Nobody awaits the task, so an exception is never observed or logged.
- Scoped services get disposed. As soon as the response is sent, the request scope ends. If
IEmailServiceor anything it depends on, such as aDbContext, is scoped, the background work hits anObjectDisposedException, sometimes, depending on timing. - Work is lost on shutdown. A deployment or scale-in stops the process, and nothing knows that unfinished task existed.
Do it properly: hand the work to a background service
Queue the work and process it in a hosted service with its own scope. A Channel<T> makes a simple in-memory queue:
builder.Services.AddSingleton(Channel.CreateBounded<Guid>(1000));
builder.Services.AddHostedService<WelcomeEmailWorker>();
public class WelcomeEmailWorker(Channel<Guid> queue, IServiceScopeFactory scopes,
ILogger<WelcomeEmailWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var userId in queue.Reader.ReadAllAsync(stoppingToken))
{
try
{
await using var scope = scopes.CreateAsyncScope();
var email = scope.ServiceProvider.GetRequiredService<IEmailService>();
await email.SendWelcomeAsync(userId, stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Welcome email failed for user {UserId}", userId);
}
}
}
}
The endpoint writes user.Id to the channel and returns. Errors are logged, each item gets its own scope, and the worker stops cleanly on shutdown.
An in-memory channel still loses queued items if the process dies. If the work must happen, use a durable queue such as Azure Service Bus or Storage queues instead.
Takeaway
Never write async void outside event handlers, and never fire off untracked tasks from a web request. Queue background work to a hosted service, or a durable queue when it can't be lost.