.NET Core
.NET Background Services: Hosted Services and Worker Pattern
Background processing is the backbone of any serious .NET backend. The moment your application needs to send an email, generate a report, synchronize data, or publish domain events, you are dealing with work that does not belong inside an HTTP request-response cycle. Pushing these tasks into background services improves response times, isolates failures, and makes your system fundamentally more scalable.
In this article I walk through the main implementation options in .NET, share production-tested patterns, and highlight the mistakes I see most often in real codebases.
Main Implementation Options
IHostedService
IHostedService is the lowest-level abstraction. It gives you two hooks — StartAsync and StopAsync — and nothing else. This is perfect when you need to spin up a connection, warm a cache, or register a listener at application startup.
public class CacheWarmupService : IHostedService
{
private readonly ICacheProvider _cache;
private readonly ILogger<CacheWarmupService> _logger;
public CacheWarmupService(ICacheProvider cache, ILogger<CacheWarmupService> logger)
{
_cache = cache;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Warming up cache...");
await _cache.LoadFrequentDataAsync(cancellationToken);
_logger.LogInformation("Cache warmup completed.");
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Application shutting down, cache warmup service stopping.");
return Task.CompletedTask;
}
}Register it in Program.cs:
builder.Services.AddHostedService<CacheWarmupService>();BackgroundService
BackgroundService is the workhorse. It inherits from IHostedService and provides an ExecuteAsync method that runs for the entire lifetime of the application. This is where you implement continuous workers — queue consumers, polling loops, file watchers.
public class OrderProcessorWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OrderProcessorWorker> _logger;
public OrderProcessorWorker(
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessorWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Order processor started.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var orderQueue = scope.ServiceProvider.GetRequiredService<IOrderQueue>();
var handler = scope.ServiceProvider.GetRequiredService<IOrderHandler>();
var order = await orderQueue.DequeueAsync(stoppingToken);
if (order is not null)
{
await handler.ProcessAsync(order, stoppingToken);
_logger.LogInformation("Order {OrderId} processed.", order.Id);
}
else
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Graceful shutdown — expected, do not log as error
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing order. Retrying in 10 seconds.");
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}
_logger.LogInformation("Order processor stopped.");
}
}A critical detail: BackgroundService is registered as a singleton, but your DbContext and most business services are scoped. Always create a new IServiceScope inside the loop. Forgetting this is one of the most common bugs I encounter.
Queue-Based Background Processing with Channel\<T\>
For in-process producer-consumer scenarios, System.Threading.Channels.Channel<T> is lightweight and extremely fast. I use this when I need to offload work from an API controller without reaching for an external message broker.
public class BackgroundTaskQueue
{
private readonly Channel<Func<IServiceProvider, CancellationToken, Task>> _channel;
public BackgroundTaskQueue(int capacity = 100)
{
var options = new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait
};
_channel = Channel.CreateBounded<Func<IServiceProvider, CancellationToken, Task>>(options);
}
public async ValueTask EnqueueAsync(
Func<IServiceProvider, CancellationToken, Task> workItem,
CancellationToken cancellationToken = default)
{
await _channel.Writer.WriteAsync(workItem, cancellationToken);
}
public async ValueTask<Func<IServiceProvider, CancellationToken, Task>> DequeueAsync(
CancellationToken cancellationToken)
{
return await _channel.Reader.ReadAsync(cancellationToken);
}
}
public class QueueProcessorWorker : BackgroundService
{
private readonly BackgroundTaskQueue _queue;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<QueueProcessorWorker> _logger;
public QueueProcessorWorker(
BackgroundTaskQueue queue,
IServiceScopeFactory scopeFactory,
ILogger<QueueProcessorWorker> logger)
{
_queue = queue;
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var workItem = await _queue.DequeueAsync(stoppingToken);
using var scope = _scopeFactory.CreateScope();
try
{
await workItem(scope.ServiceProvider, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing queued work item.");
}
}
}
}Hangfire for Scheduled and Recurring Jobs
When you need persistent, scheduled, or recurring jobs with a dashboard and automatic retries, Hangfire is the most practical choice in the .NET ecosystem.
// Program.cs
builder.Services.AddHangfire(config =>
config.UseSqlServerStorage(builder.Configuration.GetConnectionString("HangfireDb")));
builder.Services.AddHangfireServer();
// Schedule a recurring job
RecurringJob.AddOrUpdate<IReportService>(
"daily-sales-report",
service => service.GenerateDailySalesReportAsync(CancellationToken.None),
Cron.Daily(hour: 2, minute: 0));
// Fire-and-forget from a controller
BackgroundJob.Enqueue<IEmailService>(
service => service.SendWelcomeEmailAsync(userId, CancellationToken.None));
// Delayed job
BackgroundJob.Schedule<ICleanupService>(
service => service.RemoveExpiredTokensAsync(CancellationToken.None),
TimeSpan.FromHours(1));In production systems, I always configure Hangfire with a dedicated database and set explicit queue names. This prevents background job storage from competing with your application's primary database and lets you scale workers independently.
The Outbox Pattern for Reliable Event Publishing
One of the hardest problems in distributed systems is ensuring that a database write and an event publish happen atomically. If you save an order to the database but the message broker is down, the event is lost. If you publish the event but the database transaction rolls back, you have a phantom event.
The Outbox Pattern solves this by writing events to an outbox table within the same database transaction, then using a background service to relay those events to the message broker.
// Step 1: Write to outbox inside the same transaction
public class OrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
public async Task PlaceOrderAsync(Order order, CancellationToken ct)
{
_db.Orders.Add(order);
_db.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
Type = "OrderPlaced",
Payload = JsonSerializer.Serialize(new OrderPlacedEvent(order.Id, order.Total)),
CreatedAt = DateTime.UtcNow,
ProcessedAt = null
});
await _db.SaveChangesAsync(ct); // Single transaction
}
}
// Step 2: Background worker relays outbox messages
public class OutboxPublisherWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OutboxPublisherWorker> _logger;
public OutboxPublisherWorker(
IServiceScopeFactory scopeFactory,
ILogger<OutboxPublisherWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
var pending = await db.OutboxMessages
.Where(m => m.ProcessedAt == null)
.OrderBy(m => m.CreatedAt)
.Take(50)
.ToListAsync(stoppingToken);
foreach (var message in pending)
{
try
{
await bus.PublishAsync(message.Type, message.Payload, stoppingToken);
message.ProcessedAt = DateTime.UtcNow;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish outbox message {Id}.", message.Id);
break; // Preserve ordering — retry on next cycle
}
}
await db.SaveChangesAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}In production systems, I always add a RetryCount column to the outbox table and skip messages that have exceeded a threshold. Dead messages get moved to a separate table for manual inspection.
Error Handling and Retry Strategy
A background service that crashes on the first exception is worse than no background service at all. Robust error handling is non-negotiable.
Exponential Backoff with Polly
// Define a retry policy
private static readonly AsyncRetryPolicy RetryPolicy = Policy
.Handle<HttpRequestException>()
.Or<TimeoutException>()
.WaitAndRetryAsync(
retryCount: 5,
sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
onRetry: (exception, delay, attempt, context) =>
{
// Structured logging inside retry
Log.Warning(exception,
"Retry {Attempt} after {Delay}s for {Operation}.",
attempt, delay.TotalSeconds, context.OperationKey);
});
// Usage inside a worker
await RetryPolicy.ExecuteAsync(
async ct => await externalApi.SyncDataAsync(ct),
stoppingToken);Graceful Shutdown with CancellationToken
The CancellationToken passed to ExecuteAsync signals when the host is shutting down. Respecting it is critical — if your worker ignores it, the host will forcefully terminate it after the shutdown timeout (default 30 seconds).
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var batch = await FetchBatchAsync(stoppingToken);
foreach (var item in batch)
{
if (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Shutdown requested. Stopping mid-batch.");
return;
}
await ProcessItemAsync(item, stoppingToken);
}
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}In production systems, I always configure the shutdown timeout explicitly in Program.cs:
builder.Host.ConfigureHostOptions(opts =>
{
opts.ShutdownTimeout = TimeSpan.FromSeconds(60);
});This gives long-running batch operations enough time to finish their current item before the process exits.
Monitoring Background Jobs
Background services run silently. Without proper monitoring, failures go unnoticed for hours or days. I have seen systems where a background worker had been crashed for weeks before anyone noticed.
Health Checks
public class WorkerHealthCheck : IHealthCheck
{
private static DateTime _lastHeartbeat = DateTime.MinValue;
private static readonly TimeSpan Threshold = TimeSpan.FromMinutes(5);
public static void RecordHeartbeat() => _lastHeartbeat = DateTime.UtcNow;
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var elapsed = DateTime.UtcNow - _lastHeartbeat;
if (elapsed < Threshold)
return Task.FromResult(HealthCheckResult.Healthy($"Last heartbeat {elapsed.TotalSeconds:F0}s ago."));
return Task.FromResult(HealthCheckResult.Unhealthy($"No heartbeat for {elapsed.TotalMinutes:F1} minutes."));
}
}Call WorkerHealthCheck.RecordHeartbeat() at the end of each successful loop iteration in your worker.
Structured Metrics
// Using .NET 8 Metrics API
private readonly Counter<long> _processedCounter;
private readonly Histogram<double> _processingDuration;
public OrderProcessorWorker(IMeterFactory meterFactory, /* ... */)
{
var meter = meterFactory.Create("App.BackgroundJobs");
_processedCounter = meter.CreateCounter<long>("jobs.processed");
_processingDuration = meter.CreateHistogram<double>("jobs.duration_ms");
}
// Inside the processing loop
var sw = Stopwatch.StartNew();
await handler.ProcessAsync(order, stoppingToken);
sw.Stop();
_processedCounter.Add(1, new KeyValuePair<string, object?>("job_type", "order"));
_processingDuration.Record(sw.Elapsed.TotalMilliseconds);Export these metrics to Prometheus, Grafana, or Application Insights, and set up alerts when the processing rate drops or error count spikes.
Common Background Service Mistakes
1. Not Creating a Service Scope
This is the number one mistake. BackgroundService is a singleton. If you inject a scoped service like DbContext directly through the constructor, you get a captive dependency that will eventually throw ObjectDisposedException or worse — silently reuse a stale context.
// WRONG — DbContext is scoped, worker is singleton
public class BadWorker : BackgroundService
{
private readonly AppDbContext _db; // Captive dependency!
public BadWorker(AppDbContext db) => _db = db;
}
// CORRECT — create a scope each iteration
public class GoodWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public GoodWorker(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Use db safely here
}
}
}2. Swallowing Exceptions Silently
An unhandled exception in ExecuteAsync will silently stop the worker in .NET 6+. The host continues running, but the background service is dead. Always wrap your loop body in a try-catch and log the error.
3. Blocking the Host Startup
ExecuteAsync is called during host startup. If your implementation does not yield (for example, a synchronous while(true) loop without await), it blocks all other hosted services from starting. Always use await inside your loop — even await Task.Yield() at the top of the loop is enough to unblock startup.
4. Ignoring Idempotency
Background jobs may execute more than once. Network timeouts, process restarts, and duplicate queue messages all cause retries. If your handler is not idempotent, you end up with duplicate emails, double charges, or corrupted data. Design every job handler to be safe to re-execute.
5. No Dead-Letter Strategy
When a message fails repeatedly, it should not block the queue forever. Implement a retry counter and move poison messages to a dead-letter queue or table after a set number of attempts. In production systems, I always pair this with an alert so the team investigates failed messages promptly.
Conclusion
Background services are not a secondary concern — they are core infrastructure. A well-designed background processing layer handles failures gracefully, publishes events reliably through the outbox pattern, and gives your team full visibility through health checks and metrics. The patterns I have described here come from years of running these services in production, and they consistently prevent the most painful incidents.
I can help design a robust background processing architecture for your workloads.
Related Articles
Building RESTful APIs with ASP.NET Core
Learn the fundamentals of building production-ready REST APIs with ASP.NET Core. Controllers, routing, and best practices.
Microservices Architecture with .NET: Design and Implementation
Design microservices architecture with .NET. Service communication, Docker, and orchestration strategies.
.NET Performance Optimization: Profiling and Best Practices
Optimize .NET application performance. Profiling tools, memory management, and async patterns.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch