IHostedService and the Host Lifetime

Order start-up and shutdown work with IHostedService and IHostedLifecycleService, and finish warm-up work before the API accepts traffic.

Syntaxbuilder.Services.AddHostedService<PriceListLoader>(); sealed class PriceListLoader : IHostedService { public Task StartAsync(CancellationToken ct) => LoadAsync(ct); public Task StopAsync(CancellationToken ct) => Task.CompletedTask; }

An IHostedService has two methods, StartAsync and StopAsync, and the host calls them. AddHostedService<T>() registers it as a singleton. Everything long-running in a .NET process hangs off this interface, including Kestrel.

Start and stop order

By default the host starts hosted services one at a time, in registration order, awaiting each StartAsync before calling the next. It stops them in reverse order. HostOptions.ServicesStartConcurrently and ServicesStopConcurrently (both false by default) start or stop them in parallel instead.

In a WebApplication, Kestrel's GenericWebHostService is registered after everything in builder.Services. Two consequences follow: during your StartAsync the port is not open yet, and at shutdown the server stops accepting and drains requests before your StopAsync runs, so a cache or a channel a request still needs is still there.

IHostedLifecycleService

IHostedLifecycleService extends the interface with four hooks. For a host started with StartAsync and stopped by a signal, the sequence is:

  1. StartingAsync on every service, then StartAsync on every service, then StartedAsync on every service (the server is listening by now).
  2. ApplicationStarted fires.
  3. On SIGTERM or StopApplication(): ApplicationStopping fires, then StoppingAsync, StopAsync and StoppedAsync, each in reverse order.
  4. ApplicationStopped fires.

If your own code calls host.StopAsync() directly, the StoppingAsync hooks run before ApplicationStopping fires; do not depend on the relative order of those two.

Work that must finish before traffic

Loading a price list, priming a cache or checking that the schema version matches belongs in StartAsync: the server does not listen until it returns, so no request sees an empty cache. If it throws, start-up fails, the host logs "Hosting failed to start" and the process exits. Throw only for deterministic faults (bad configuration, a schema version mismatch, malformed data), which a restart cannot fix. For a dependency that may be unreachable, retry with backoff, or run the warm-up in the background and gate the readiness check on it, so the pod stays up and unready instead of crash-looping (see Failing Fast at Start-Up). HostOptions.StartupTimeout is infinite by default, so put your own timeout on network calls, and give slow starters a Kubernetes startup probe.

BackgroundService is the wrong tool for this. In .NET 10 its StartAsync runs the whole of ExecuteAsync in the background and returns at once, so the server can open before your loop has done anything. Use it for loops that run for the life of the process.

What goes wrong in production

  • A loop in StartAsync. while (true) there means start-up never completes: no port, no ApplicationStarted, a pod killed by its probes.
  • Calling your own API from StartAsync. The server is not listening yet; use StartedAsync or ApplicationStarted.
  • Swallowed warm-up failures. Catching and logging means the instance serves traffic with an empty cache. Throw on a deterministic fault; for an outage, retry or stay unready.
  • Scoped dependencies. A hosted service is a singleton; inject IServiceScopeFactory and create a scope for a DbContext.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Load the active price list into memory before Kestrel listens, so the first GET /prices request after a deploy never sees an empty cache.
  • Fail a rollout when the catalog schema version does not match the one the build expects, instead of serving 500s for every product.
  • Flush buffered audit events in StopAsync after Kestrel has drained in-flight requests, relying on reverse stop order.

More examples

Warm-up with a scope and a deadline

The hosted service is a singleton, so it borrows a scope for the scoped repository and disposes it when the warm-up ends. The linked token gives the warm-up a 20 second budget, so start-up fails with <code>OperationCanceledException</code> instead of hanging until a probe kills the pod. A pod restarted during a database outage would crash-loop on that, so for a dependency that can be down, retry inside the budget or move the warm-up behind a readiness check, as in Failing Fast at Start-Up.

Example · csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ICatalogRepository, CatalogRepository>();
builder.Services.AddSingleton<ProductCache>();
builder.Services.AddHostedService<ProductCacheWarmup>();

var app = builder.Build();
app.MapGet("/products/{id:int}", (int id, ProductCache cache) =>
    cache.Find(id) is { } p ? Results.Ok(p) : Results.NotFound());
app.Run();

record Product(int Id, string Name, decimal Price);

interface ICatalogRepository { Task<List<Product>> GetActiveAsync(CancellationToken ct); }

sealed class CatalogRepository : ICatalogRepository
{
    public Task<List<Product>> GetActiveAsync(CancellationToken ct) =>
        Task.FromResult(new List<Product> { new(1, "Blue kettle", 39.00m), new(2, "Tea tray", 18.50m) });
}

sealed class ProductCache
{
    private Dictionary<int, Product> _items = new();
    public void Fill(IEnumerable<Product> products) => _items = products.ToDictionary(p => p.Id);
    public Product? Find(int id) => _items.GetValueOrDefault(id);
}

sealed class ProductCacheWarmup(IServiceScopeFactory scopes, ProductCache cache) : IHostedService
{
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        // StartupTimeout is infinite by default, so bound the warm-up ourselves.
        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        deadline.CancelAfter(TimeSpan.FromSeconds(20));

        await using var scope = scopes.CreateAsyncScope();
        var repository = scope.ServiceProvider.GetRequiredService<ICatalogRepository>();
        cache.Fill(await repository.GetActiveAsync(deadline.Token));
    }

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

Starting independent services in parallel

Sequential start makes these two loaders cost 7 seconds; concurrent start costs about 4. Only switch it on when the services are independent, because it removes the ordering guarantee your other services may rely on. <code>StartupTimeout</code> cancels the token passed to <code>StartAsync</code> when it expires and the start fails.

Example · csharp
var builder = Host.CreateApplicationBuilder(args);

builder.Services.Configure<HostOptions>(options =>
{
    options.ServicesStartConcurrently = true;
    options.ServicesStopConcurrently = true;
    options.StartupTimeout = TimeSpan.FromSeconds(60);
});

builder.Services.AddHostedService<TaxRatesLoader>();
builder.Services.AddHostedService<ShippingZonesLoader>();

using var host = builder.Build();
await host.RunAsync();

sealed class TaxRatesLoader : IHostedService
{
    public Task StartAsync(CancellationToken ct) => Task.Delay(TimeSpan.FromSeconds(3), ct);
    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

sealed class ShippingZonesLoader : IHostedService
{
    public Task StartAsync(CancellationToken ct) => Task.Delay(TimeSpan.FromSeconds(4), ct);
    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

Pitfall: a loop in StartAsync

With the broken version the host is stuck inside <code>StartAsync</code>: later hosted services, including Kestrel in a web app, never start, and <code>ApplicationStarted</code> never fires. <code>BackgroundService</code> exists for exactly this shape: its <code>StartAsync</code> returns immediately and its <code>StopAsync</code> cancels <code>stoppingToken</code> and waits for the loop to finish.

Example · csharp
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<InventorySyncFixed>();
using var host = builder.Build();
await host.RunAsync();

// Wrong: StartAsync never returns, so the host never finishes starting.
sealed class InventorySyncBroken : IHostedService
{
    public async Task StartAsync(CancellationToken ct)
    {
        while (true)
        {
            await Task.Delay(TimeSpan.FromMinutes(1), ct);
        }
    }

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

// Right: BackgroundService runs ExecuteAsync in the background and cancels it on stop.
sealed class InventorySyncFixed(ILogger<InventorySyncFixed> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            logger.LogInformation("Inventory synced");
        }
    }
}

Discussion

  • Be the first to comment on this lesson.