Lost exceptions, disposed scoped services, lost work on shutdown, no backpressure; safe alternatives with BackgroundService, Channel of T and durable queues.
Fire-and-forget means starting a task and not awaiting it, such as _ = SendEmailAsync(). The risks are: exceptions are never observed, scoped services like DbContext are disposed when the request ends (giving ObjectDisposedException), work is lost on shutdown or crash, there is no limit on how much work piles up, and HttpContext may be used after the request is gone. In ASP.NET Core, hand the work to a queue (Channel<T>) processed by a BackgroundService that creates its own scope, logs failures and drains on shutdown. If the work must not be lost, use a durable queue or an outbox table.
"Fire-and-forget means starting async work and not awaiting it:
_ = SendEmailAsync(user);or_ = Task.Run(...)inside a request. In ASP.NET Core that creates five problems. Exceptions are never observed, so failures disappear silently. Scoped services captured from the request, likeDbContext, are disposed when the response completes.HttpContextis recycled and not thread-safe after the request. Work in memory is lost on shutdown, deployment, scale-in or crash. And there's no backpressure: a traffic spike can start thousands of background tasks and exhaust memory or downstream connections. My default replacement is a boundedChannel<T>that request code writes small messages to (IDs, not entities), and aBackgroundServicethat reads it, creates a DI scope per item, logs and retries failures, and drains on shutdown. If the work must not be lost, I persist intent: a transactional outbox or a durable broker with idempotent consumers."
| Risk | Mechanism | Symptom |
|---|---|---|
| Lost exceptions | Nobody awaits the task | Missing results, clean logs |
| Disposed dependencies | Request scope disposed when the response is sent | ObjectDisposedException (often never logged) |
HttpContext misuse |
Recycled after the request; not thread-safe | Null or another request's data, crashes |
| Lost work | Process stops (SIGTERM, scale-in, crash, OOM) | Gaps that appear during deployments |
| No backpressure | Unlimited tasks during spikes | Memory growth, socket/connection exhaustion, downstream overload |
| Context flow | ExecutionContext (activity, scopes) flows into the task |
Spans that end after their parents; confusing traces |
| Scale-out blindness | Each instance has its own in-memory work | No visibility of total backlog |
| How important is the work? | Mechanism |
|---|---|
| Best effort (cache warm-up, telemetry ping) | Logged fire-and-forget wrapper |
| Should happen; rare loss tolerable | Bounded Channel<T> + BackgroundService |
| Must happen (effectively once) | Transactional outbox + broker + idempotent consumer |
| Scheduled / retried / visible jobs | Hangfire, Quartz.NET, cloud schedulers/functions |
| Piece | Responsibility |
|---|---|
Bounded Channel<T> (singleton) |
Holds work; capacity limits memory |
| Request handler | Writes a small message (IDs, correlation id) and returns 202 Accepted; never captures HttpContext or scoped services |
BackgroundService |
Reads with ReadAllAsync(stoppingToken), creates CreateAsyncScope() per item, try/catch + logging + retry |
| Concurrency limit | One reader, or N readers / Parallel.ForEachAsync with a cap |
| Shutdown | Stop accepting, drain within HostOptions.ShutdownTimeout, log leftovers |
| Metrics | Queue depth, processing time, failures |
When the channel is full: WriteAsync (request waits, natural backpressure) or TryWrite and return 503 / 429 so clients retry.
| Option | Guarantee | Cost |
|---|---|---|
| Outbox: write an outbox row in the same DB transaction as the business change; a worker publishes | Survives crashes; atomic with the business write | Publisher process; duplicates possible → idempotent consumers |
| Durable broker (Service Bus, RabbitMQ, SQS, Kafka) | Retries, dead-letter, scale across instances | Infrastructure; at-least-once semantics |
| Job framework (Hangfire, Quartz.NET) | Persistence, scheduling, dashboards | Another dependency |
Our [sign-up API] sent welcome emails with _ = Task.Run(() => _email.SendAsync(user)), and the email service used the request's DbContext to load templates. Support received tickets about missing welcome emails, [about 3%] of sign-ups and more under load, yet logs were clean. In staging I wrapped the call in a logging try/catch and immediately saw ObjectDisposedException from the DbContext: the request scope had been disposed after the response. I introduced a bounded Channel<WelcomeEmail> with capacity [1,000], an EmailWorker BackgroundService that creates a scope per message and logs failures with retries, and a drain on shutdown. Missing emails dropped to [zero]; later we moved to an outbox so even a crash couldn't lose a sign-up email. We also banned _ = Task.Run in request code with an analyser and a review checklist.
A production-focused check on async safety in web apps.
Green flags
BackgroundService and a scope per item.HttpContext.Red flags
request starts -> scope created (DbContext, IEmailSender, ...)
handler: _ = Task.Run(() => sender.SendAsync(user)) // captures scoped services
handler returns 200
response completes -> scope.DisposeAsync() -> DbContext disposed
background task (100 ms later) -> DbContext.Query(...) -> ObjectDisposedException
nobody awaits -> unobserved -> silently lost
public sealed class EmailWorker(
Channel<WelcomeEmail> queue,
IServiceScopeFactory scopes,
ILogger<EmailWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var item in queue.Reader.ReadAllAsync(stoppingToken))
await ProcessWithRetryAsync(item, stoppingToken);
}
private async Task ProcessWithRetryAsync(WelcomeEmail item, CancellationToken ct)
{
for (int attempt = 1; ; attempt++)
{
try
{
await using var scope = scopes.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
await sender.SendAsync(item.UserId, ct);
return;
}
catch (Exception ex) when (ex is not OperationCanceledException && attempt < 3)
{
log.LogWarning(ex, "Attempt {Attempt} failed for {UserId}", attempt, item.UserId);
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
log.LogError(ex, "Giving up on welcome email for {UserId}", item.UserId);
return; // or write to a dead-letter store
}
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
queue.Writer.TryComplete();
await base.StopAsync(cancellationToken);
log.LogInformation("Email worker stopped with {Remaining} items unprocessed", queue.Reader.Count);
}
}
BoundedChannelFullMode |
Producer behaviour when full |
|---|---|
Wait |
WriteAsync waits (backpressure); TryWrite returns false |
DropOldest |
Oldest item discarded |
DropNewest |
Newest queued item discarded |
DropWrite |
The item being written is discarded |
For HTTP requests I prefer TryWrite + 503/429 over making requests wait indefinitely.
await using var tx = await db.Database.BeginTransactionAsync(ct);
db.Users.Add(user);
db.Outbox.Add(new OutboxMessage("WelcomeEmail", JsonSerializer.Serialize(new { user.Id })));
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
// A separate worker polls Outbox, publishes, marks rows as sent. Consumers dedupe by message id.
Both the user and the intent to email are committed together: no lost emails, at the cost of possible duplicates (handled by idempotency).
public static class Background
{
public static void FireAndForget(Func<Task> work, ILogger log, string name)
{
_ = RunAsync();
async Task RunAsync()
{
try { await work().ConfigureAwait(false); }
catch (Exception ex) { log.LogWarning(ex, "Best-effort task {Name} failed", name); }
}
}
}
Only for work that genuinely doesn't matter if lost, and that doesn't use scoped services.
The background task inherits the request's ExecutionContext, so Activity.Current is the request span. If the work outlives the request, spans end after their parent and traces look broken. In a worker, start a new activity linked to the request's trace context (pass the trace id in the message).
| Version | Change |
|---|---|
| .NET Core 2.1 | BackgroundService |
| .NET Core 3.0 | System.Threading.Channels in-box |
| .NET 6 | CreateAsyncScope(); unhandled BackgroundService exceptions stop the host by default |
| .NET 8 | IHostedLifecycleService; concurrent hosted service start/stop options |
| .NET 10 | Same patterns |
DbContext inside _ = Task.Run(async () => { await Task.Delay(500); ... }), with logging added to see the exception.bombardier, and compare Wait vs TryWrite + 503.using System.Threading.Channels;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(Channel.CreateBounded<WelcomeEmail>(
new BoundedChannelOptions(1_000) { FullMode = BoundedChannelFullMode.Wait }));
builder.Services.AddScoped<IEmailSender, ConsoleEmailSender>();
builder.Services.AddHostedService<EmailWorker>();
var app = builder.Build();
app.MapPost("/users/{id:int}", async (int id, Channel<WelcomeEmail> queue, CancellationToken ct) =>
{
// Queue a small message with ids only; never capture HttpContext or scoped services.
await queue.Writer.WriteAsync(new WelcomeEmail(id), ct);
return Results.Accepted();
});
app.Run();
public sealed record WelcomeEmail(int UserId);
public interface IEmailSender { Task SendAsync(int userId, CancellationToken ct); }
public sealed class ConsoleEmailSender : IEmailSender
{
public Task SendAsync(int userId, CancellationToken ct)
{
Console.WriteLine($"Welcome email to user {userId}");
return Task.CompletedTask;
}
}
public sealed class EmailWorker(
Channel<WelcomeEmail> queue,
IServiceScopeFactory scopes,
ILogger<EmailWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var item in queue.Reader.ReadAllAsync(stoppingToken))
{
try
{
await using var scope = scopes.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
await sender.SendAsync(item.UserId, stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
log.LogError(ex, "Welcome email failed for {UserId}", item.UserId);
}
}
}
}
_ = Task.Run(...) capturing scoped servicesWhat happens: the task uses the request's DbContext or other scoped services.
Why it is wrong: the scope is disposed when the response completes → ObjectDisposedException.
Do instead: queue IDs; create a new scope per item in a worker.
HttpContext in background workWhat happens: HttpContext.User, headers or RequestServices read after the request.
Why it is wrong: recycled and not thread-safe; may show another request's data.
Do instead: copy needed values (user id, correlation id) into the message.
What happens: Channel.CreateUnbounded<T>() or a ConcurrentQueue for background work.
Why it is wrong: spikes or slow dependencies grow memory without limit.
Do instead: bounded channel with a deliberate full mode and metrics on depth.
What happens: payments, emails, integration events live only in a channel. Why it is wrong: deployments, crashes and OOM kills lose them. Do instead: outbox or durable broker with idempotent consumers.
What happens: one exception escapes ExecuteAsync.
Why it is wrong: since .NET 6 the host stops by default; before that, the worker silently died.
Do instead: try/catch per item, log, retry, dead-letter.
What happens: the BackgroundService creates one scope at startup and reuses its DbContext.
Why it is wrong: the context grows (change tracker), isn't thread-safe and accumulates stale state.
Do instead: scope per item or per small batch.
What happens: the worker loops with Task.Delay(1000) without the token.
Why it is wrong: shutdown waits for the timeout and then cuts work off.
Do instead: pass stoppingToken everywhere; drain deliberately.
What happens: WriteAsync without a timeout under sustained overload.
Why it is wrong: request threads pile up and latency explodes.
Do instead: TryWrite + 503/429, or WriteAsync with a short timeout.
Task.Run to "not wait"What happens: _ = Task.Run(() => client.PostAsync(...)).
Why it is wrong: an extra pool hop plus every fire-and-forget risk.
Do instead: queue it, or await it if the caller needs the result.
What happens: no metrics on queue depth, failures or processing time. Why it is wrong: backlogs and failures are invisible until users complain. Do instead: expose queue depth and failure counters; alert on growth and errors.
Every background operation needs answers to four questions: who observes failures, what scope it runs in, what limits its volume, and what happens on restart. Fire-and-forget answers none of them.
| Work importance | Mechanism |
|---|---|
| Best effort (cache warm-up) | Logged fire-and-forget wrapper |
| Should happen, loss tolerable | Channel<T> + BackgroundService |
| Must happen exactly once in effect | Outbox + broker + idempotent consumer |
| Scheduled or retried jobs | Hangfire, Quartz.NET or a cloud scheduler |
An exception inside a task or async method is captured and stored in the task, which becomes Faulted. await rethrows the first stored exception with its original stack; .Wait()/.Result throw an AggregateException. await Task.WhenAll(...) also throws only the first, so inspect each task (or the WhenAll task's Exception.InnerExceptions) to see all failures. OperationCanceledException for the task's token makes it Canceled, not Faulted. Exceptions in tasks nobody observes raise TaskScheduler.UnobservedTaskException later and are otherwise ignored. Exceptions in async void go to the SynchronizationContext or the ThreadPool and crash the process.
A foreground thread keeps the process alive: the runtime waits for every foreground thread to finish before the process exits. A background thread does not: when the last foreground thread ends, background threads are stopped immediately and their finally blocks do not run. New Thread objects are foreground by default; ThreadPool threads (and therefore Task.Run work) are always background. So background work needs an explicit graceful shutdown, or it is cut off mid-operation.
A dependency is the weakest relationship: class A uses class B, so a change to B may affect A, but A does not keep B as part of its state. It shows up as a method parameter, a local variable, a return type, a static call or a new B() inside a method. Association is stronger: A stores a reference to B. Direction matters: high-level policy (business rules) should not depend on low-level details (SQL, HTTP). The Dependency Inversion Principle says both should depend on abstractions owned by the high-level side. Dependency injection supplies those abstractions from outside (usually through constructors), so classes do not create or find their own dependencies, which makes them testable and replaceable.
How to tell it (STAR)
Users reported missing welcome emails but our logs were clean. I found the emails were fired and forgotten from the request, using a `DbContext` that was already disposed, and nobody observed the exception. I moved the work to a bounded channel with a background worker that created its own scope and logged failures. Missing emails dropped to [zero], and we later used an outbox for guaranteed delivery.
Last updated 31 Oct 2026.