📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are the risks of fire-and-forget tasks in .NET, and how would you run background work safely in ASP.NET Core?

Lost exceptions, disposed scoped services, lost work on shutdown, no backpressure; safe alternatives with BackgroundService, Channel of T and durable queues.

Senior RealWorldScenario Advanced aspnet-core async background-service fire-and-forget

🎯 Key points

  • Fire-and-forget tasks have unobserved exceptions
  • Captured scoped services and HttpContext are invalid after the request ends
  • In-memory work is lost on shutdown or crash
  • Unbounded background work has no backpressure
  • Use a bounded Channel<T> with a BackgroundService and a scope per item
  • Use an outbox or durable broker for work that must not be lost
  • Copy ids into the message instead of capturing request objects

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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, like DbContext, are disposed when the response completes. HttpContext is 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 bounded Channel<T> that request code writes small messages to (IDs, not entities), and a BackgroundService that 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."


1. What goes wrong

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

2. Decide by importance

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

3. The safe in-process pattern

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.

4. When it must not be lost

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

Production example

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.

🔍 What the interviewer is checking

A production-focused check on async safety in web apps.

Green flags

  • Lists exceptions, scope disposal, shutdown loss and backpressure.
  • Proposes a bounded channel with a BackgroundService and a scope per item.
  • Escalates to durable queues or outbox for important work.
  • Copies data rather than capturing HttpContext.

Red flags

  • "Just use Task.Run, it runs in the background."
  • No thought about shutdown or failure visibility.

📚 Learn in depth

1. Why the request scope is gone

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

2. A complete worker with retries and drain

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);
    }
}

3. Bounded channel options

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.

4. Transactional outbox sketch

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).

5. A logged best-effort wrapper

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.

6. Context and tracing

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).

7. Version notes

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

8. Try it yourself

  1. Reproduce the bug: minimal API using a scoped DbContext inside _ = Task.Run(async () => { await Task.Delay(500); ... }), with logging added to see the exception.
  2. Replace it with the code example's channel + worker and confirm no errors.
  3. Set channel capacity to 10, flood the endpoint with bombardier, and compare Wait vs TryWrite + 503.
  4. Press Ctrl+C with 500 items queued and log how many were processed before shutdown.
  5. Implement a simple outbox table and publisher; kill the process mid-run and verify nothing is lost.
  6. Look at the trace in Aspire/Jaeger for fire-and-forget vs worker-with-new-activity.

💻 Code

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);
            }
        }
    }
}

⚠ Common mistakes

1. _ = Task.Run(...) capturing scoped services

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

2. Using HttpContext in background work

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

3. Unbounded in-memory queues

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.

4. In-memory work for must-not-lose operations

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.

5. No error handling in the worker loop

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.

6. One scope for the worker's whole lifetime

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.

7. Ignoring the stopping token

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.

8. Making requests wait forever when the queue is full

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.

9. Wrapping already-async calls in 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.

10. Not monitoring background work

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says exceptions get lost Why: Checks async exception rules
    How to answer

    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.

  2. When: Candidate mentions work lost at shutdown Why: Probes background threads and graceful shutdown
    How to answer

    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.

  3. When: Candidate mentions scoped services Why: Checks DI lifetimes
    How to answer

    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.

🛠 Real-world problems: how you handle them

Welcome emails failing silently with ObjectDisposedException
Situation
A [sign-up API] sent welcome emails with `_ = Task.Run(...)` using services from the request scope.
Signals
Support tickets about missing welcome emails; no errors in logs; [about 3%] of sign-ups affected, more under load.
Investigation
I wrapped the call in a logging try/catch in staging and saw `ObjectDisposedException` from `DbContext` after the response completed.
Resolution
Introduced a bounded `Channel<WelcomeEmail>` and an `EmailWorker` `BackgroundService` creating a scope per message; later moved to an outbox for guaranteed delivery.
Prevention
Banned `_ = Task.Run` in request code via an analyser and code review checklist.

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.

↩ Usually asked after

More in Tasks

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me