Background Services & Hosted Services in .NET
Build long-running background tasks in ASP.NET Core with IHostedService and BackgroundService — scheduled jobs, queue workers, health-monitored services, and the Outbox pattern.
IHostedService vs BackgroundService
| | IHostedService | BackgroundService |
|---|---|---|
| What it is | Interface with StartAsync / StopAsync | Abstract class implementing IHostedService |
| Use when | You need full control over start/stop lifecycle | You need a long-running loop |
| Main method | StartAsync / StopAsync | ExecuteAsync(CancellationToken) |
| Error handling | Manual | Loop keeps running if you handle exceptions |
BackgroundService implements IHostedService under the hood — it's the easier starting point for most background work.
BackgroundService: The Basics
public abstract class BackgroundService : IHostedService, IDisposable
{
protected abstract Task ExecuteAsync(CancellationToken stoppingToken);
public virtual Task StartAsync(CancellationToken cancellationToken)
{
// Starts ExecuteAsync on a background thread
_ = ExecuteAsync(stoppingToken);
return Task.CompletedTask;
}
public virtual Task StopAsync(CancellationToken cancellationToken)
{
// Signals stoppingToken cancellation
...
}
}Your job: implement ExecuteAsync. When the stoppingToken is cancelled (app shutdown), the loop exits cleanly.
Pattern 1: Polling Worker
Polls a data source on a fixed interval:
public class OrderSyncWorker : BackgroundService
{
private readonly ILogger<OrderSyncWorker> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeSpan _interval = TimeSpan.FromMinutes(5);
public OrderSyncWorker(
ILogger<OrderSyncWorker> logger,
IServiceScopeFactory scopeFactory)
{
_logger = logger;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("OrderSyncWorker starting.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await DoWorkAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Normal shutdown — don't log as error
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during order sync. Retrying in {Interval}.", _interval);
}
await Task.Delay(_interval, stoppingToken);
}
_logger.LogInformation("OrderSyncWorker stopping.");
}
private async Task DoWorkAsync(CancellationToken ct)
{
// BackgroundService is singleton; DbContext is scoped
// Always create a scope for scoped dependencies
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var syncService = scope.ServiceProvider.GetRequiredService<IOrderSyncService>();
var pendingOrders = await db.Orders
.Where(o => o.Status == OrderStatus.Pending && o.SyncedAt == null)
.Take(100)
.ToListAsync(ct);
_logger.LogInformation("Syncing {Count} orders.", pendingOrders.Count);
foreach (var order in pendingOrders)
{
await syncService.SyncToExternalSystemAsync(order, ct);
}
await db.SaveChangesAsync(ct);
}
}Critical: background services are registered as singletons, but DbContext is scoped. Never inject DbContext directly into a background service — always use IServiceScopeFactory.
Pattern 2: Scheduled Job (Cron-Style)
Run a job at a specific time each day:
public class DailyReportWorker : BackgroundService
{
private readonly ILogger<DailyReportWorker> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeOnly _runAt = new(2, 0); // 2:00 AM UTC
public DailyReportWorker(ILogger<DailyReportWorker> logger, IServiceScopeFactory scopeFactory)
{
_logger = logger;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var now = TimeOnly.FromDateTime(DateTime.UtcNow);
var delay = CalculateDelayUntil(_runAt, now);
_logger.LogInformation("Next report run in {Delay}.", delay);
await Task.Delay(delay, stoppingToken);
if (stoppingToken.IsCancellationRequested) break;
try
{
await GenerateReportAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate daily report.");
}
}
}
private static TimeSpan CalculateDelayUntil(TimeOnly target, TimeOnly current)
{
var delay = target - current;
if (delay < TimeSpan.Zero) delay += TimeSpan.FromHours(24);
return delay;
}
private async Task GenerateReportAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var reporter = scope.ServiceProvider.GetRequiredService<IReportService>();
await reporter.GenerateDailyReportAsync(DateTime.UtcNow.Date.AddDays(-1), ct);
_logger.LogInformation("Daily report generated.");
}
}Pattern 3: Channel-Based Queue Worker
Process work items from an in-memory queue. The Channel<T> class from System.Threading.Channels is the modern, high-performance producer/consumer channel.
Background Queue
// Application/Common/Interfaces/IBackgroundTaskQueue.cs
public interface IBackgroundTaskQueue
{
ValueTask QueueAsync(Func<IServiceProvider, CancellationToken, ValueTask> workItem);
ValueTask<Func<IServiceProvider, CancellationToken, ValueTask>> DequeueAsync(CancellationToken ct);
}// Infrastructure/Background/BackgroundTaskQueue.cs
public class BackgroundTaskQueue : IBackgroundTaskQueue
{
private readonly Channel<Func<IServiceProvider, CancellationToken, ValueTask>> _queue;
public BackgroundTaskQueue(int capacity = 100)
{
_queue = Channel.CreateBounded<Func<IServiceProvider, CancellationToken, ValueTask>>(
new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait
}
);
}
public async ValueTask QueueAsync(Func<IServiceProvider, CancellationToken, ValueTask> workItem)
=> await _queue.Writer.WriteAsync(workItem);
public async ValueTask<Func<IServiceProvider, CancellationToken, ValueTask>> DequeueAsync(
CancellationToken ct)
=> await _queue.Reader.ReadAsync(ct);
}Queue Worker
public class QueuedHostedService : BackgroundService
{
private readonly IBackgroundTaskQueue _queue;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<QueuedHostedService> _logger;
public QueuedHostedService(
IBackgroundTaskQueue queue,
IServiceScopeFactory scopeFactory,
ILogger<QueuedHostedService> logger)
{
_queue = queue;
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var workItem = await _queue.DequeueAsync(stoppingToken);
try
{
using var scope = _scopeFactory.CreateScope();
await workItem(scope.ServiceProvider, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing background queue item.");
}
}
}
}Enqueueing Work from a Controller
[HttpPost("{id}/process")]
public async Task<IActionResult> ProcessOrder(int id)
{
// Return 202 immediately, process in background
await _queue.QueueAsync(async (sp, ct) =>
{
var processor = sp.GetRequiredService<IOrderProcessor>();
await processor.ProcessAsync(id, ct);
});
return Accepted(new { message = "Order processing queued." });
}Pattern 4: Outbox Pattern Worker
The Outbox pattern guarantees that domain events are published even if the message broker is unavailable at write time. Events are stored in the DB atomically with the business data, then a background worker relays them to the bus.
// Domain event stored in DB
public class OutboxMessage
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Type { get; set; } = default!;
public string Payload { get; set; } = default!; // JSON
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ProcessedAt { get; set; }
public string? Error { get; set; }
public int RetryCount { get; set; }
}// Background worker that processes outbox messages
public class OutboxWorker : BackgroundService
{
private readonly ILogger<OutboxWorker> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeSpan _interval = TimeSpan.FromSeconds(10);
public OutboxWorker(ILogger<OutboxWorker> logger, IServiceScopeFactory scopeFactory)
{
_logger = logger;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await ProcessOutboxMessagesAsync(stoppingToken);
await Task.Delay(_interval, stoppingToken);
}
}
private async Task ProcessOutboxMessagesAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
var messages = await db.OutboxMessages
.Where(m => m.ProcessedAt == null && m.RetryCount < 5)
.OrderBy(m => m.CreatedAt)
.Take(20)
.ToListAsync(ct);
foreach (var message in messages)
{
try
{
var eventType = Type.GetType(message.Type)
?? throw new InvalidOperationException($"Unknown event type: {message.Type}");
var payload = JsonSerializer.Deserialize(message.Payload, eventType)!;
await bus.PublishAsync(payload, ct);
message.ProcessedAt = DateTime.UtcNow;
_logger.LogInformation("Published outbox message {Id} of type {Type}.", message.Id, message.Type);
}
catch (Exception ex)
{
message.RetryCount++;
message.Error = ex.Message;
_logger.LogWarning(ex, "Failed to publish outbox message {Id}.", message.Id);
}
}
await db.SaveChangesAsync(ct);
}
}IHostedService: Full Lifecycle Control
Use raw IHostedService when you need to start/stop external resources:
public class TcpListenerService : IHostedService
{
private TcpListener? _listener;
private Task? _listenTask;
private readonly CancellationTokenSource _cts = new();
private readonly ILogger<TcpListenerService> _logger;
public TcpListenerService(ILogger<TcpListenerService> logger) => _logger = logger;
public Task StartAsync(CancellationToken cancellationToken)
{
_listener = new TcpListener(IPAddress.Any, 2575); // MLLP port
_listener.Start();
_listenTask = AcceptConnectionsAsync(_cts.Token);
_logger.LogInformation("TCP listener started on port 2575.");
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_cts.Cancel();
_listener?.Stop();
if (_listenTask is not null)
await Task.WhenAny(_listenTask, Task.Delay(5000, cancellationToken));
_logger.LogInformation("TCP listener stopped.");
}
private async Task AcceptConnectionsAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var client = await _listener!.AcceptTcpClientAsync(ct);
_ = HandleClientAsync(client, ct); // fire-and-forget per connection
}
catch (OperationCanceledException) { break; }
}
}
private async Task HandleClientAsync(TcpClient client, CancellationToken ct)
{
using (client)
{
// handle HL7/MLLP messages...
}
}
}Registration
// Program.cs
// Register as hosted services
builder.Services.AddHostedService<OrderSyncWorker>();
builder.Services.AddHostedService<DailyReportWorker>();
builder.Services.AddHostedService<QueuedHostedService>();
builder.Services.AddHostedService<OutboxWorker>();
// The in-memory queue as singleton (shared between producer controllers and consumer worker)
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();Health Checks for Background Services
Report background service health to the /health endpoint:
public class OrderSyncWorkerHealthCheck : IHealthCheck
{
private readonly OrderSyncWorker _worker;
public OrderSyncWorkerHealthCheck(OrderSyncWorker worker) => _worker = worker;
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken ct = default)
{
return Task.FromResult(
_worker.IsHealthy
? HealthCheckResult.Healthy("Order sync worker is running.")
: HealthCheckResult.Unhealthy("Order sync worker has stopped unexpectedly.")
);
}
}builder.Services.AddHealthChecks()
.AddCheck<OrderSyncWorkerHealthCheck>("order-sync-worker");
app.MapHealthChecks("/health");Key Takeaways
BackgroundServiceis the standard starting point — implementExecuteAsyncwith a loop and cancel onstoppingToken- Background services are singleton — always use
IServiceScopeFactoryto resolve scoped services likeDbContext - Catch all exceptions inside the loop — an uncaught exception terminates the worker silently
Channel<T>is the correct producer/consumer queue for in-process background work — don't useConcurrentQueue+ polling- The Outbox pattern solves the dual-write problem: write to DB and publish an event atomically — relay via a background worker
- Use
IHostedServicedirectly when you need to manage external resources (TCP listeners, timers, connection pools) - Add health checks to every background service so Kubernetes/load balancers can detect stuck workers
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.