BackgroundService is an abstract base class that simplifies the most common hosted service pattern: a long-running loop that continues until cancellation is requested. You implement a single method — ExecuteAsync(CancellationToken stoppingToken) — and the base class handles registration, StartAsync/StopAsync plumbing, and error propagation. The pattern is used for queue processors, event consumers, periodic cleanup tasks, and any work that runs continuously alongside the web application.
BackgroundService with Retry and Scope
public class OrderProcessingService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OrderProcessingService> _logger;
public OrderProcessingService(
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessingService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Order processor starting.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessPendingOrdersAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break; // clean shutdown — exit the loop
}
catch (Exception ex)
{
_logger.LogError(ex, "Order processing failed — retrying in 30s.");
// Exponential-ish backoff: wait before retrying after error
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
_logger.LogInformation("Order processor stopped.");
}
private async Task ProcessPendingOrdersAsync(CancellationToken ct)
{
// Create a fresh scope per iteration — DbContext is Scoped
using var scope = _scopeFactory.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
var processor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();
var pendingOrders = await repo.GetPendingAsync(ct);
foreach (var order in pendingOrders)
{
await processor.ProcessAsync(order, ct);
_logger.LogInformation("Order {OrderId} processed.", order.Id);
}
}
}
builder.Services.AddHostedService<OrderProcessingService>();
stoppingToken in ExecuteAsync is triggered when the host initiates shutdown. All async operations inside the loop should accept this token — database queries (repo.GetPendingAsync(ct)), HTTP calls, and delays (Task.Delay(delay, ct)). When the token fires, these operations throw OperationCanceledException, which the loop catches and uses to exit cleanly. If the loop does not respond to the token within the shutdown timeout, the host kills the process — operations in progress are aborted mid-execution.IServiceScopeFactory scope for each unit of work inside the background loop, not once for the whole service. DbContext accumulates change tracker state — reusing one context across iterations leaks state from one batch to the next. Creating and disposing a scope per iteration is the cleanest pattern: all scoped services are fresh, any accumulated state is discarded, and the database connection is returned to the pool after each unit of work.ExecuteAsync throws an unhandled exception (one not caught by the loop’s catch block), the background service stops permanently but the host continues running. The application serves HTTP requests normally but the background work has silently ceased. Unhandled exceptions in background services are logged at Critical level. Add monitoring/alerting on Critical log entries from background services — silent failure of background processing is a common production issue that goes undetected without explicit monitoring.Testing BackgroundService
// Test a background service by running it with a controlled cancellation
[Fact]
public async Task OrderProcessor_ProcessesPendingOrders_OnIteration()
{
// Arrange
var cts = new CancellationTokenSource();
var mockRepo = new Mock<IOrderRepository>();
mockRepo.Setup(r => r.GetPendingAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Order> { new Order { Id = 1 } });
var services = new ServiceCollection()
.AddScoped(_ => mockRepo.Object)
.AddScoped<IOrderProcessor, OrderProcessor>()
.BuildServiceProvider();
var service = new OrderProcessingService(
services.GetRequiredService<IServiceScopeFactory>(),
Mock.Of<ILogger<OrderProcessingService>>());
// Act — run one iteration then cancel
var task = service.StartAsync(cts.Token);
await Task.Delay(100); // let one iteration run
cts.Cancel();
await task;
// Assert
mockRepo.Verify(r => r.GetPendingAsync(It.IsAny<CancellationToken>()), Times.AtLeastOnce);
}
Common Mistakes
Mistake 1 — Not catching OperationCanceledException (unclean shutdown logs errors)
❌ Wrong — OperationCanceledException propagates as an unhandled error on shutdown:
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); // throws on cancel — not caught!
✅ Correct — catch with when (stoppingToken.IsCancellationRequested) and exit the loop.
Mistake 2 — Reusing one DI scope for the entire service lifetime (stale DbContext)
❌ Wrong — one DbContext accumulates state across all iterations, leaks connections.
✅ Correct — create and dispose a scope per iteration inside the loop.