BackgroundService gives you one method: ExecuteAsync. It starts when the host starts and runs until the host shuts down. Simple. But what if you need to warm a cache after the app is fully ready to receive requests? Or flush a queue before connections start draining? ExecuteAsync fires during startup — you can’t distinguish between “the host is starting” and “the host is fully started and ready.”
.NET 8 introduced IHostedLifecycleService, which adds four lifecycle hooks around the existing Start/Stop flow:
The Lifecycle
StartingAsync → StartAsync → StartedAsync ↓ (app is running) ↓StoppingAsync → StopAsync → StoppedAsyncPractical Example: Cache Warming + Graceful Drain
public class OrderProcessingService( IServiceScopeFactory scopeFactory, Channel<Order> orderChannel, ILogger<OrderProcessingService> logger) : IHostedLifecycleService{ private Task? _processingTask; private CancellationTokenSource? _cts;
// Called BEFORE the host marks itself as started public Task StartingAsync(CancellationToken ct) { logger.LogInformation("Preparing order processor..."); return Task.CompletedTask; }
public Task StartAsync(CancellationToken ct) { _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); _processingTask = ProcessOrdersAsync(_cts.Token); return Task.CompletedTask; }
// Called AFTER the host is fully started (Kestrel is listening, etc.) public async Task StartedAsync(CancellationToken ct) { logger.LogInformation("Host is ready. Warming the product cache...");
using var scope = scopeFactory.CreateScope(); var cache = scope.ServiceProvider.GetRequiredService<IProductCache>(); await cache.WarmAsync(ct);
logger.LogInformation("Cache warmed. Ready to process orders."); }
// Called BEFORE StopAsync — signal your work to wind down public Task StoppingAsync(CancellationToken ct) { logger.LogInformation("Shutdown signal received. Completing channel..."); orderChannel.Writer.Complete(); return Task.CompletedTask; }
public async Task StopAsync(CancellationToken ct) { if (_processingTask is not null) { _cts?.Cancel(); await _processingTask; } }
// Called AFTER StopAsync — final cleanup public Task StoppedAsync(CancellationToken ct) { logger.LogInformation("Order processor fully stopped. All items flushed."); _cts?.Dispose(); return Task.CompletedTask; }
private async Task ProcessOrdersAsync(CancellationToken ct) { await foreach (var order in orderChannel.Reader.ReadAllAsync(ct)) { await ProcessOrderAsync(order); } }
private Task ProcessOrderAsync(Order order) { logger.LogInformation("Processed order {Id}", order.Id); return Task.CompletedTask; }}Registration
builder.Services.AddHostedService<OrderProcessingService>();That’s it. The DI container sees it implements IHostedLifecycleService and calls the lifecycle methods at the right time.
When to Use Which Hook
| Hook | Fires When | Use For |
|---|---|---|
StartingAsync | Before StartAsync | Pre-start validation, logging |
StartedAsync | After ALL hosted services have started | Cache warming, readiness probes |
StoppingAsync | Before StopAsync | Signal producers to stop, close writers |
StoppedAsync | After StopAsync completes | Final cleanup, metrics flush |
Key Takeaway
IHostedLifecycleService gives you four hooks around the start/stop lifecycle so you can separate “begin starting” from “fully ready” and “begin stopping” from “fully stopped.” Use StartedAsync for work that needs the full app running, and StoppingAsync to gracefully drain before shutdown.