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 → StoppedAsync

Practical 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

HookFires WhenUse For
StartingAsyncBefore StartAsyncPre-start validation, logging
StartedAsyncAfter ALL hosted services have startedCache warming, readiness probes
StoppingAsyncBefore StopAsyncSignal producers to stop, close writers
StoppedAsyncAfter StopAsync completesFinal 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.