BackgroundService
IHostedService and BackgroundService run work with no request behind it, and the stopping token is how the host asks them to finish.
protected override Task ExecuteAsync(CancellationToken stoppingToken)A background service is code that runs inside your application with no request behind it. It drains a queue, sends email, deletes expired rows. Nothing calls it. It just runs.
In .NET the host runs it for you. The host is the object that starts your application, holds its services and shuts it down. You hand the host a class, and it starts that class on start-up and stops it on shutdown.
IHostedService and BackgroundService
IHostedService is an interface with two methods, StartAsync and StopAsync. An interface is a job description, not the worker. The host calls StartAsync on every registered hosted service before the application finishes starting.
BackgroundService is an abstract class that implements IHostedService so you do not have to. You override one method, ExecuteAsync, and put your loop inside it. The host passes a CancellationToken, normally named stoppingToken, and cancels that token when the application is shutting down. Checking it every pass is the whole contract.
Register it with builder.Services.AddHostedService<InvoiceWorker>(). One instance is created and reused for the life of the process, so it cannot take a scoped service such as a DbContext in its constructor. The two constructor forms in the examples below are the same code: a primary constructor is shorthand for an explicit constructor that assigns a private field.
Where it bites
An exception you do not catch inside ExecuteAsync stops the entire host, and the process exits. That default is BackgroundServiceExceptionBehavior.StopHost and it has applied since .NET 6; before that the exception was swallowed and the worker died quietly. Catch per-item failures inside the loop so one bad message does not end the application.
Example
// BackgroundService lives in the Microsoft.Extensions.Hosting package, which a
// plain console app does not reference. This is the same loop written by hand,
// so the shape is visible: the host owns the token and cancels it to ask you
// to stop.
string[] firstBatch = ["invoice-1", "invoice-2", "invoice-3"];
Queue<string> jobs = new(firstBatch);
JobLoop loop = new();
using CancellationTokenSource running = new();
await loop.ExecuteAsync(jobs, running.Token);
string[] secondBatch = ["invoice-4", "invoice-5"];
Queue<string> leftovers = new(secondBatch);
using CancellationTokenSource stopping = new();
stopping.Cancel(); // what a SIGTERM ends up doing
await loop.ExecuteAsync(leftovers, stopping.Token);
sealed class JobLoop
{
public async Task ExecuteAsync(Queue<string> jobs, CancellationToken stoppingToken)
{
// Checking the token every pass is the whole contract.
while (!stoppingToken.IsCancellationRequested && jobs.Count > 0)
{
string job = jobs.Dequeue();
await Task.Delay(1, stoppingToken); // stands in for the real work
Console.WriteLine($"handled {job}");
}
Console.WriteLine($"loop exited, {jobs.Count} left");
}
}
// Output:
// handled invoice-1
// handled invoice-2
// handled invoice-3
// loop exited, 0 left
// loop exited, 2 leftWhen to use it
- Poll an external API every few seconds and write new rows into your own database.
- Send queued welcome emails without making the sign-up request wait for the mail server.
- Delete expired sessions and temporary uploads on a schedule inside the same process as the API.
- Warm a cache once at start-up before the service begins serving traffic.
More examples
The real BackgroundService, longhand constructor
This is the shape you write in production: one loop, a per-item try/catch so a single failure is logged rather than fatal, and a separate catch for the cancellation that shutdown causes. Reach for it whenever the work is continuous. The constructor here is the long form; compare it with the primary constructor in the next example.
// InvoiceWorker.cs, in a project that references Microsoft.Extensions.Hosting.
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Billing;
public sealed class InvoiceWorker : BackgroundService
{
private readonly ILogger<InvoiceWorker> _logger;
// Longhand: a constructor parameter copied into a private field.
public InvoiceWorker(ILogger<InvoiceWorker> logger)
{
_logger = logger;
}
// The host calls this once and cancels stoppingToken at shutdown.
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await SendOneInvoiceAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break; // normal shutdown, not a failure
}
catch (Exception ex)
{
// Caught here, so one bad invoice does not stop the host.
_logger.LogError(ex, "Invoice send failed");
}
}
}
private static Task SendOneInvoiceAsync(CancellationToken cancellationToken) =>
Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}Registering it, and choosing what an unhandled exception does
Every hosted service has to be registered or it never runs. Set BackgroundServiceExceptionBehavior deliberately: StopHost is usually what you want under Kubernetes, because a crash loop is visible and a silently dead worker is not.
// Program.cs of a worker service.
using Billing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
// AddHostedService registers one instance for the life of the process.
builder.Services.AddHostedService<InvoiceWorker>();
builder.Services.AddHostedService<SchemaCheck>();
builder.Services.Configure<HostOptions>(options =>
{
// StopHost is the default since .NET 6: an unhandled exception in
// ExecuteAsync ends the process, so your orchestrator restarts it.
// Ignore keeps the host alive but leaves that worker dead until a restart.
options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost;
});
using IHost host = builder.Build();
await host.RunAsync();Raw IHostedService, with a primary constructor
Implement IHostedService directly when the work is a short step at start-up or shutdown rather than a loop: a schema check, a cache warm-up, a one-time registration. Remember that the host waits for StartAsync, so anything slow here delays the whole application.
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Billing;
// Shorthand: a primary constructor. The parameter `logger` is usable in the
// body exactly as a private field would be. This is the same as writing a
// constructor that assigns _logger.
public sealed class SchemaCheck(ILogger<SchemaCheck> logger) : IHostedService
{
// StartAsync blocks start-up until it returns, so keep it short.
public Task StartAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Schema checked");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
Discussion