Introduction
Many applications need to perform work outside the normal HTTP request cycle. Examples include sending notifications, processing uploaded files, synchronizing data, and periodically checking external systems.
ASP.NET Core provides hosted services for running background work alongside the application. They are straightforward to implement, but reliability requires more than placing a loop inside ExecuteAsync.
This article covers the essential design decisions for building maintainable background jobs in .NET.
1. Understand the role of a hosted service
A hosted service implements IHostedService, while BackgroundService provides a convenient base class for long-running asynchronous work.
A minimal example looks like this:
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public sealed class CleanupWorker(
ILogger<CleanupWorker> logger
) : BackgroundService
{
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Cleanup worker is running at {Time}",
DateTimeOffset.UtcNow);
await Task.Delay(
TimeSpan.FromMinutes(5),
stoppingToken);
}
}
}
Register the worker:
builder.Services.AddHostedService<CleanupWorker>();
The host starts the service with the application and requests its cancellation during shutdown. The cancellation token should be passed to operations that support it.
2. Respect dependency injection lifetimes
Hosted services are generally registered as singletons. That means a scoped dependency, such as an Entity Framework Core DbContext, should not simply be injected into the worker’s constructor.
Create a scope for each unit of work:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
public sealed class ReportWorker(
IServiceScopeFactory scopeFactory
) : BackgroundService
{
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await using var scope =
scopeFactory.CreateAsyncScope();
var processor = scope.ServiceProvider
.GetRequiredService<IReportProcessor>();
await processor.ProcessPendingAsync(stoppingToken);
await Task.Delay(
TimeSpan.FromSeconds(30),
stoppingToken);
}
}
}
This pattern creates a new dependency injection scope for each iteration. Scoped services are disposed when the scope ends, which helps avoid keeping database contexts alive for the entire lifetime of the application.
In production code, the worker should also handle expected processing failures so that one failed iteration does not unintentionally terminate the entire background service.
3. Handle cancellation and failures deliberately
Cancellation is part of the normal application lifecycle. A worker should stop accepting new work during shutdown and give ongoing operations a reasonable opportunity to finish.
Avoid swallowing every exception:
try
{
await processor.ProcessPendingAsync(stoppingToken);
}
catch (OperationCanceledException)
when (stoppingToken.IsCancellationRequested)
{
// Expected during shutdown.
}
catch (Exception exception)
{
logger.LogError(exception, "Background processing failed");
}
Logging a failure is only the first step. The application must also decide whether to retry, abandon, reschedule, or move the item to a dead-letter queue.
Retries should be bounded and use delays, often with exponential backoff and jitter. Retrying a permanent validation error indefinitely wastes resources and makes the original problem harder to identify.
4. Design for duplicate execution
A background job may run more than once for the same logical item. This can happen after a process crash, a timeout, or a retry.
For that reason, design important operations to be idempotent: processing the same job twice should not create duplicate payments, send repeated irreversible commands, or corrupt state.
Common techniques include:
- Unique job IDs and database constraints.
- Processed-message records.
- Idempotency keys for external API calls.
- Explicit job states and transactional updates.
- Safe retry policies.
A database transaction can protect changes within one database, but it does not automatically make an external email or HTTP request transactional. Those workflows require additional design.
5. Know when an in-process worker is insufficient
Hosted services are appropriate for simple recurring tasks and work that can tolerate the lifecycle of the web application. They are not automatically durable job queues.
If the process stops, in-memory queued work can be lost. If several application instances run the same worker, each instance may execute the same scheduled task unless coordination is implemented.
For durable or high-volume workloads, consider a persistent queue or a dedicated job-processing system. Use database leases, distributed locks, or queue semantics where appropriate, while understanding the failure modes of each mechanism.
6. Add operational visibility
A background worker should expose enough information to answer practical questions:
- Is it running?
- How many jobs are waiting?
- How long does processing take?
- How many jobs fail or retry?
- When did the last successful run occur?
Use structured logs and metrics rather than relying on occasional informational messages. Health checks should distinguish application availability from background processing health where that distinction matters.
Conclusion
Reliable background services require clear dependency lifetimes, cancellation support, error handling, idempotency, and operational visibility. Start with BackgroundService for straightforward workloads, but move to durable queues or dedicated workers when job persistence, coordination, or scaling becomes a requirement.