BackgroundService

Write a BackgroundService loop that survives bad items, stops cleanly on cancellation and does not die silently or exit with a misleading code.

Syntaxpublic sealed class InvoiceSender : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { /* loop until cancelled */ } } builder.Services.AddHostedService<InvoiceSender>();

BackgroundService is the base class for long-running work inside the host: queue consumers, pollers, relays. It implements IHostedService; you override one method, ExecuteAsync(CancellationToken stoppingToken), and the host runs it for the life of the process.

The mechanism

StartAsync creates a token source, starts ExecuteAsync and stores the task in ExecuteTask. In .NET 10 the whole of ExecuteAsync runs as a thread-pool task, so a synchronous first section (a blocking call before the first await) no longer holds up the other hosted services or Kestrel. Older versions ran that section on the start-up thread, which is why legacy code starts with await Task.Yield().

StopAsync cancels stoppingToken, then waits for ExecuteTask to finish or for the host's shutdown token to fire, whichever comes first. The host gives all services together HostOptions.ShutdownTimeout, 30 seconds by default. Work still running after that is abandoned when the process exits.

When ExecuteAsync throws

BackgroundServiceExceptionBehaviorWhat happensUse it when
StopHost (default)The host logs BackgroundService failed at Error and calls StopApplication(). Every other service, Kestrel included, shuts down.Almost always: the orchestrator restarts a clean process.
IgnoreThe exception is logged; the worker is dead but the process keeps serving HTTP.Rarely. Only with a health check that reports the dead worker.

The trap with StopHost: RunAsync returns normally and the process exits with code 0. A Kubernetes Deployment restarts the pod anyway, but a Job, a CronJob or a systemd unit with Restart=on-failure reads 0 as success. Check ExecuteTask.IsFaulted after the host stops and set Environment.ExitCode = 1.

The trap with a catch-all: wrapping the loop body in catch (Exception) also catches the OperationCanceledException thrown at shutdown, so every deploy logs a spurious error (and a while (true) loop that delays without the token keeps spinning until the shutdown timeout). Filter on the token, not the exception type: catch (Exception ex) when (!stoppingToken.IsCancellationRequested). A cancellation the stopping token did not cause, such as an HttpClient timeout, is an ordinary transient failure; when (ex is not OperationCanceledException) lets it escape and, under StopHost, stops the whole host.

What a team decides

  • Per-item failures (one malformed invoice) are caught inside the loop, logged with the item id and parked. They must not stop the worker.
  • Systemic failures (credentials rejected, schema missing) escape and stop the host, so the process restarts and alerts fire. Retrying them forever in a loop hides the outage.
  • Transient failures (broker unavailable) get a backoff delay, not a tight loop that writes 10,000 log lines a second.
  • Scoped dependencies such as a DbContext come from a new scope per iteration via IServiceScopeFactory; a hosted service is a singleton.

Pass stoppingToken to every await. A Task.Delay(TimeSpan.FromMinutes(5)) without it ignores shutdown and turns every deploy into a 30-second forced stop.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Consume order-placed messages for the life of the API process and stop cleanly within the 30-second shutdown window on every deploy.
  • Cancel orders left unpaid for 48 hours every five minutes, using a fresh DbContext scope per pass.
  • Make a reconciliation worker that hits revoked credentials stop the pod and fail its Job with exit code 1 instead of reporting success.

More examples

A scope per iteration for scoped dependencies

A hosted service is a singleton, so injecting a scoped repository or <code>DbContext</code> into its constructor fails scope validation in Development and leaks one change tracker forever in Production. <code>CreateAsyncScope</code> per pass gives each iteration its own scoped graph and disposes it, including <code>IAsyncDisposable</code> services, before the delay.

Example · csharp
public interface IOrderRepository
{
    Task<IReadOnlyList<int>> FindUnpaidOlderThanAsync(TimeSpan age, CancellationToken ct);
    Task CancelAsync(int orderId, CancellationToken ct);
}

public sealed class UnpaidOrderCanceller(
    IServiceScopeFactory scopeFactory,
    ILogger<UnpaidOrderCanceller> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // A fresh scope per pass: a new DbContext, a new change tracker, disposed at the end.
            await using (var scope = scopeFactory.CreateAsyncScope())
            {
                var orders = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
                foreach (var id in await orders.FindUnpaidOlderThanAsync(TimeSpan.FromHours(48), stoppingToken))
                {
                    await orders.CancelAsync(id, stoppingToken);
                    logger.LogInformation("Cancelled unpaid order {OrderId}", id);
                }
            }

            await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
        }
    }
}

Pitfall: a catch-all that swallows shutdown

Once the stopping token is cancelled, the filter lets the <code>OperationCanceledException</code> escape, which <code>BackgroundService</code> treats as a normal stop, not a failure. A cancellation from a per-call timeout arrives while the token is still live, so it is retried like any transient failure. Transient errors get an exponential backoff capped at one minute, so a broker outage produces a handful of log lines instead of a tight loop that floods the log pipeline.

Example · csharp
public interface IPaymentWebhookInbox
{
    Task<bool> ProcessNextAsync(CancellationToken ct);
}

public sealed class WebhookProcessor(IPaymentWebhookInbox inbox, ILogger<WebhookProcessor> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var backoff = TimeSpan.FromSeconds(1);
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                var processed = await inbox.ProcessNextAsync(stoppingToken);
                backoff = TimeSpan.FromSeconds(1);
                if (!processed) await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
            }
            // Without the filter, the cancellation at shutdown is logged as an error. Filtering on the token,
            // not on the exception type, keeps an HttpClient timeout on the retry path.
            catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
            {
                logger.LogError(ex, "Webhook processing failed; retrying in {Backoff}", backoff);
                await Task.Delay(backoff, stoppingToken);
                backoff = TimeSpan.FromTicks(Math.Min(backoff.Ticks * 2, TimeSpan.FromMinutes(1).Ticks));
            }
        }
    }
}

Report a dead or stuck worker through health checks

A worker can hang on a call without a timeout while the HTTP side stays healthy. The worker records a heartbeat each pass, and the liveness check fails when the beat is older than two minutes, so Kubernetes restarts a pod whose consumer is stuck. Set the threshold above the longest legitimate pass.

Example · csharp
using Microsoft.Extensions.Diagnostics.HealthChecks;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<WorkerHeartbeat>();
builder.Services.AddHostedService<ShipmentSync>();
builder.Services.AddHealthChecks().AddCheck<WorkerHeartbeatCheck>("shipment-sync", tags: ["live"]);

var app = builder.Build();
app.MapHealthChecks("/health/live");
app.Run();

public sealed class WorkerHeartbeat
{
    private long _lastBeatTicks = DateTimeOffset.UtcNow.UtcTicks;
    public void Beat() => Interlocked.Exchange(ref _lastBeatTicks, DateTimeOffset.UtcNow.UtcTicks);
    public TimeSpan SinceLastBeat => DateTimeOffset.UtcNow - new DateTimeOffset(Interlocked.Read(ref _lastBeatTicks), TimeSpan.Zero);
}

public sealed class WorkerHeartbeatCheck(WorkerHeartbeat heartbeat) : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default) =>
        Task.FromResult(heartbeat.SinceLastBeat < TimeSpan.FromMinutes(2)
            ? HealthCheckResult.Healthy()
            : HealthCheckResult.Unhealthy($"No heartbeat for {heartbeat.SinceLastBeat.TotalSeconds:F0}s"));
}

public sealed class ShipmentSync(WorkerHeartbeat heartbeat) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            heartbeat.Beat();
            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

Discussion

  • Be the first to comment on this lesson.