Background Services with IHostedService and BackgroundService
ASP.NET Core applications often need to run work outside the request pipeline — processing messages from a queue, running scheduled cleanup tasks, or warming caches on startup. The IHostedService interface and its BackgroundService base class provide a first-class way to do this.
IHostedService: The Foundation
IHostedService defines two methods:
public interface IHostedService
{
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
StartAsync is called when the application host starts, and StopAsync when it shuts down. Here's a simple startup task:
public class CacheWarmingService : IHostedService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<CacheWarmingService> _logger;
public CacheWarmingService(IServiceScopeFactory scopeFactory, ILogger<CacheWarmingService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Warming cache...");
using var scope = _scopeFactory.CreateScope();
var cache = scope.ServiceProvider.GetRequiredService<IProductCache>();
await cache.WarmAsync(cancellationToken);
_logger.LogInformation("Cache warmed successfully");
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
Register it with:
builder.Services.AddHostedService<CacheWarmingService>();
A critical detail: hosted services are registered as singletons. You cannot inject scoped services directly — use IServiceScopeFactory to create a scope, as shown above.
BackgroundService: The Convenient Base Class
For long-running background work, BackgroundService is more practical. It implements IHostedService and gives you a single ExecuteAsync method that runs for the lifetime of the application:
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 processing service started");
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();
await processor.ProcessPendingOrdersAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}
Timed Background Tasks with PeriodicTimer
.NET 6 introduced PeriodicTimer, which is a cleaner alternative to Task.Delay for recurring work:
public class MetricsCollectionService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly TimeSpan _interval = TimeSpan.FromMinutes(5);
public MetricsCollectionService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(_interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
using var scope = _scopeFactory.CreateScope();
var collector = scope.ServiceProvider.GetRequiredService<IMetricsCollector>();
await collector.CollectAsync(stoppingToken);
}
}
}
PeriodicTimer doesn't drift — it accounts for the time spent doing work between ticks.
Queued Background Work
A common pattern is to accept work from the request pipeline and process it in the background. Use a Channel<T> as the queue:
public interface IBackgroundTaskQueue
{
ValueTask EnqueueAsync(Func<IServiceProvider, CancellationToken, ValueTask> workItem);
ValueTask<Func<IServiceProvider, CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken);
}
public class BackgroundTaskQueue : IBackgroundTaskQueue
{
private readonly Channel<Func<IServiceProvider, CancellationToken, ValueTask>> _queue =
Channel.CreateBounded<Func<IServiceProvider, CancellationToken, ValueTask>>(100);
public async ValueTask EnqueueAsync(Func<IServiceProvider, CancellationToken, ValueTask> workItem)
{
await _queue.Writer.WriteAsync(workItem);
}
public async ValueTask<Func<IServiceProvider, CancellationToken, ValueTask>> DequeueAsync(
CancellationToken cancellationToken)
{
return await _queue.Reader.ReadAsync(cancellationToken);
}
}
The background service consumes from the queue:
public class QueuedBackgroundService : BackgroundService
{
private readonly IBackgroundTaskQueue _taskQueue;
private readonly IServiceScopeFactory _scopeFactory;
public QueuedBackgroundService(IBackgroundTaskQueue taskQueue, IServiceScopeFactory scopeFactory)
{
_taskQueue = taskQueue;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var workItem = await _taskQueue.DequeueAsync(stoppingToken);
using var scope = _scopeFactory.CreateScope();
await workItem(scope.ServiceProvider, stoppingToken);
}
}
}
Graceful Shutdown
The CancellationToken passed to StopAsync and ExecuteAsync signals when the host wants to shut down. By default, the host waits 30 seconds for hosted services to stop. You can configure this:
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(60);
});
In .NET 8+, you can also configure ServicesStartConcurrently and ServicesStopConcurrently to start and stop hosted services in parallel rather than sequentially:
builder.Services.Configure<HostOptions>(options =>
{
options.ServicesStartConcurrently = true;
options.ServicesStopConcurrently = true;
});
Key Considerations
Don't block in ExecuteAsync. If ExecuteAsync doesn't yield (no await), it blocks the application from starting. Always use asynchronous operations.
Handle exceptions. In .NET 6+, an unhandled exception in a hosted service stops the host by default. Wrap your work in try-catch blocks and decide whether to retry, log, or let the application crash.
Avoid fire-and-forget from controllers. If you need to offload work from a request, use the queued pattern above rather than Task.Run or discarding a task. The queue gives you backpressure, observability, and graceful shutdown handling.
Background services are one of the most useful features in ASP.NET Core. Whether you need a one-shot startup task, a recurring timer, or a full work queue, IHostedService and BackgroundService give you a clean, testable foundation.