IHostedService Lifecycle — StartAsync, StopAsync and Graceful Shutdown

IHostedService is the mechanism for running code alongside ASP.NET Core’s request pipeline — code that starts when the host starts and stops when the host stops. The interface has exactly two methods: StartAsync (called before the application begins serving requests) and StopAsync (called when the host initiates graceful shutdown). Every hosted service is a singleton registered in the DI container, and multiple hosted services start in registration order. Understanding this lifecycle precisely is the foundation for reliable background work in production ASP.NET Core applications.

IHostedService Contract

// ── Full lifecycle example ─────────────────────────────────────────────────
public class AppInitialisationService : IHostedService
{
    private readonly IServiceProvider _sp;
    private readonly ILogger<AppInitialisationService> _logger;

    public AppInitialisationService(IServiceProvider sp, ILogger<AppInitialisationService> logger)
    {
        _sp     = sp;
        _logger = logger;
    }

    // Called before any HTTP requests are served
    // Blocking here delays the application from accepting requests
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Running database migrations...");

        // Must create a scope — hosted service is singleton, DbContext is scoped
        using var scope = _sp.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        try
        {
            await db.Database.MigrateAsync(cancellationToken);
            _logger.LogInformation("Migrations applied successfully.");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to apply database migrations.");
            throw;   // re-throw to prevent the application from starting in a broken state
        }
    }

    // Called when SIGTERM or Ctrl+C is received — graceful shutdown window
    public Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("AppInitialisationService stopping.");
        return Task.CompletedTask;
    }
}

// ── Registration order matters — services start in registration order ──────
builder.Services.AddHostedService<AppInitialisationService>();   // starts first
builder.Services.AddHostedService<CacheWarmingService>();         // starts second
builder.Services.AddHostedService<NotificationQueueProcessor>();  // starts third
Note: The StopAsync cancellation token fires after the configured shutdown timeout (default: 30 seconds). If your StopAsync takes longer than the timeout, the host forcefully terminates. Configure the shutdown timeout with builder.Services.Configure<HostOptions>(opts => opts.ShutdownTimeout = TimeSpan.FromSeconds(60)). In Kubernetes, the pre-stop hook and terminationGracePeriodSeconds must be set longer than this timeout to avoid SIGKILL interrupting your graceful shutdown before it completes.
Tip: Separate startup work that must complete before requests are served (database migrations, required cache warming) from startup work that can proceed concurrently (optional cache warming, background sync). For the former, block in StartAsync. For the latter, fire a background Task inside StartAsync and return immediately — the task runs concurrently with request handling and errors are logged but do not prevent startup. Use the IHostApplicationLifetime.ApplicationStarted callback to trigger optional post-startup work.
Warning: If StartAsync throws an unhandled exception, the host refuses to start — which is usually the correct behaviour for critical initialisation failures like a failed database migration. However, if the exception is from a non-critical service (cache warming failure), catching it and logging prevents it from blocking the entire application. Design each hosted service with deliberate intent: critical services should throw on failure; non-critical services should catch, log, and continue.

Startup Sequencing

// IHostApplicationLifetime — register callbacks for lifecycle events
public class LifecycleLogger : IHostedService
{
    private readonly IHostApplicationLifetime _lifetime;
    private readonly ILogger<LifecycleLogger> _logger;

    public LifecycleLogger(IHostApplicationLifetime lifetime, ILogger<LifecycleLogger> logger)
    {
        _lifetime = lifetime;
        _logger   = logger;
    }

    public Task StartAsync(CancellationToken ct)
    {
        // Fires AFTER all StartAsync methods complete — application is ready
        _lifetime.ApplicationStarted.Register(() =>
            _logger.LogInformation("All services started — application ready for requests."));

        // Fires when shutdown is initiated (before StopAsync calls)
        _lifetime.ApplicationStopping.Register(() =>
            _logger.LogInformation("Shutdown initiated — draining in-flight requests."));

        // Fires after all StopAsync methods complete
        _lifetime.ApplicationStopped.Register(() =>
            _logger.LogInformation("Application fully stopped. All resources released."));

        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

Common Mistakes

Mistake 1 — Long-running work directly in StartAsync without a background Task

❌ Wrong — blocks all HTTP requests until complete:

public async Task StartAsync(CancellationToken ct)
{
    await LongRunningCacheWarmAsync(ct);  // app cannot serve requests for minutes!

✅ Correct — fire in background for non-critical work; only block for truly critical initialisation.

Mistake 2 — Not re-throwing critical startup failures

❌ Wrong — swallowing a migration failure lets the app start in a broken state.

✅ Correct — throw from StartAsync for critical failures; the host will not start and deployment fails fast.

🧠 Test Yourself

You have three hosted services registered. Service B’s StartAsync throws. What happens to Service C?