Queues, schedulers and cache warmers are part of most real services and must behave on shutdown.
A hosted service runs alongside the web server for the app's lifetime. Derive from BackgroundService and implement ExecuteAsync(CancellationToken stoppingToken), then register it with builder.Services.AddHostedService<T>().
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(ct)) { ... }
}Key points:
stoppingTokenis canceled when the app shuts down. Pass it to every await so shutdown is quick; the host waits up toHostOptions.ShutdownTimeout(30 seconds by default).- Hosted services are singletons. To use scoped services like a
DbContext, injectIServiceScopeFactoryand create a scope per unit of work:using var scope = factory.CreateScope();. - Catch and log exceptions inside the loop. Since .NET 6, an unhandled exception in
ExecuteAsyncstops the whole host by default (BackgroundServiceExceptionBehavior.StopHost). PeriodicTimerdoesn't overlap ticks, unlikeSystem.Threading.Timer.
For in-process work queues, combine a Channel<T> registered as a singleton: endpoints write work items and return 202 Accepted, and the background service reads them. Remember that in-memory queues are lost on restart; for durable jobs use a message broker or a library such as Hangfire or Quartz.NET.
When running multiple instances, every instance runs its hosted services, so scheduled jobs may need a distributed lock.
Example
using System.Threading.Channels;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(Channel.CreateBounded<string>(100));
builder.Services.AddHostedService<EmailWorker>();
var app = builder.Build();
app.MapPost("/emails", async (string to, Channel<string> queue) =>
{
await queue.Writer.WriteAsync(to);
return Results.Accepted(); // 202: processed later
});
app.Run();
class EmailWorker(Channel<string> queue, IServiceScopeFactory scopes, ILogger<EmailWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
await foreach (var to in queue.Reader.ReadAllAsync(stoppingToken))
{
try
{ using var scope = scopes.CreateScope(); // scoped services per item
await Task.Delay(100, stoppingToken); // send the email here
log.LogInformation("Sent email to {To}", to); }
catch (Exception ex) when (ex is not OperationCanceledException)
{ log.LogError(ex, "Failed to email {To}", to); }
}
}
}Remember
- BackgroundService.ExecuteAsync runs for the app's lifetime
- Honor stoppingToken for fast, graceful shutdown
- Create a scope per unit of work to use scoped services
- Catch exceptions in the loop; unhandled ones stop the host
Watch out for
- Injecting a DbContext directly into a hosted service constructor (a captive scoped dependency).