IHostedService and Application Lifetime

IHostedService is the interface for code that runs as part of the application host lifecycle โ€” starting when the host starts and stopping when the host stops. It is the mechanism for database migrations on startup, cache warming, background processing loops, health check registration, and any work that needs to run alongside the main request pipeline. BackgroundService is an abstract base class that simplifies the common pattern of a long-running background loop. Every production ASP.NET Core application uses at least one hosted service.

IHostedService โ€” Manual Lifecycle Control

// โ”€โ”€ IHostedService interface โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// StartAsync: called when the host starts, before requests are served
// StopAsync:  called when the host is shutting down, with a cancellation token

public class DatabaseInitialiser : IHostedService
{
    private readonly IServiceProvider _sp;
    private readonly ILogger<DatabaseInitialiser> _logger;

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

    public async Task StartAsync(CancellationToken ct)
    {
        _logger.LogInformation("Applying database migrations...");

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

        await db.Database.MigrateAsync(ct);
        _logger.LogInformation("Database migrations applied.");
    }

    public Task StopAsync(CancellationToken ct)
    {
        _logger.LogInformation("DatabaseInitialiser stopping.");
        return Task.CompletedTask;
    }
}

// Register the hosted service
builder.Services.AddHostedService<DatabaseInitialiser>();
Note: Hosted services are registered as singletons in the DI container โ€” the same instance lives for the entire application lifetime. This creates an important constraint: hosted services cannot directly inject scoped services (like DbContext or IPostRepository) into their constructor because scoped services are tied to a request lifetime, not the application lifetime. The solution is to inject IServiceProvider (or IServiceScopeFactory) into the hosted service and create a new scope for each unit of work, as shown in the DatabaseInitialiser example above.
Tip: For long-running background loops (processing queues, polling external APIs, periodic cleanup), prefer BackgroundService over implementing IHostedService directly. BackgroundService wraps the boilerplate of starting a background task and responding to stop signals. Your only responsibility is implementing ExecuteAsync(CancellationToken ct) โ€” the abstract base class handles the rest. The cancellation token passed to ExecuteAsync is triggered when the host requests shutdown, giving your loop the opportunity to finish current work and exit cleanly.
Warning: If StartAsync blocks for a long time (e.g., waiting for an external service that is unavailable), the application will not accept HTTP requests until it completes. For startup work that may fail or take a long time, consider: (1) running it in the background (fire-and-forget with error handling) and exposing a health check that reports not-ready until the work completes, or (2) using a shorter timeout with a retry mechanism. Never let a hosted service’s StartAsync block indefinitely on an external dependency that could be unavailable.

BackgroundService โ€” Long-Running Background Loops

// BackgroundService โ€” simplifies the background loop pattern
public class NotificationQueueProcessor : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<NotificationQueueProcessor> _logger;

    public NotificationQueueProcessor(
        IServiceScopeFactory scopeFactory,
        ILogger<NotificationQueueProcessor> logger)
    {
        _scopeFactory = scopeFactory;
        _logger       = logger;
    }

    // Called once by the host โ€” runs for the lifetime of the application
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Notification processor starting.");

        // Loop until the host requests cancellation
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await ProcessPendingNotificationsAsync(stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                // Expected on shutdown โ€” exit the loop cleanly
                break;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error processing notifications โ€” retrying in 30s.");
                await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
            }
        }

        _logger.LogInformation("Notification processor stopped.");
    }

    private async Task ProcessPendingNotificationsAsync(CancellationToken ct)
    {
        using var scope   = _scopeFactory.CreateScope();
        var notifService  = scope.ServiceProvider
            .GetRequiredService<INotificationService>();
        await notifService.ProcessPendingAsync(ct);
        await Task.Delay(TimeSpan.FromSeconds(5), ct);  // poll interval
    }
}

// Register
builder.Services.AddHostedService<NotificationQueueProcessor>();

Common Mistakes

Mistake 1 โ€” Injecting scoped services directly into a hosted service constructor

โŒ Wrong โ€” InvalidOperationException: cannot consume scoped service from singleton:

public class MyBackgroundService(IPostRepository repo) : BackgroundService { }
// IPostRepository is scoped โ€” injecting it into a singleton background service fails!

โœ… Correct โ€” inject IServiceScopeFactory and create a scope for each unit of work.

Mistake 2 โ€” Not handling OperationCanceledException in the background loop

โŒ Wrong โ€” unhandled OperationCanceledException on shutdown logs an error and may prevent graceful termination.

โœ… Correct โ€” catch it with when (stoppingToken.IsCancellationRequested) and exit the loop cleanly.

🧠 Test Yourself

Why can’t a BackgroundService inject IPostRepository directly via its constructor, even though IPostRepository is registered in the DI container?