Background services

ASP.NET Core · Advanced · 12 min

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:

  • stoppingToken is canceled when the app shuts down. Pass it to every await so shutdown is quick; the host waits up to HostOptions.ShutdownTimeout (30 seconds by default).
  • Hosted services are singletons. To use scoped services like a DbContext, inject IServiceScopeFactory and 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 ExecuteAsync stops the whole host by default (BackgroundServiceExceptionBehavior.StopHost).
  • PeriodicTimer doesn't overlap ticks, unlike System.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).

Check yourself

  1. How should a BackgroundService use a scoped DbContext?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services