ASP.NET Core cookbook · Intermediate

Run periodic background work with scoped services

A background job needs the DbContext (scoped) and must stop cleanly on shutdown.

Create a free account to save this page, take notes and track your progress. Sign in

Approach

Implement BackgroundService with a PeriodicTimer, create a DI scope per run to resolve scoped services, catch and log failures so one bad run doesn't kill the loop, and honour the stopping token.

Solution

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ICartCleaner, CartCleaner>();
builder.Services.AddHostedService<ExpiredCartsJob>();
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20));
var app = builder.Build();
app.Run();

public interface ICartCleaner { Task<int> DeleteExpiredAsync(CancellationToken ct); }

public class CartCleaner : ICartCleaner
{
    public Task<int> DeleteExpiredAsync(CancellationToken ct) => Task.FromResult(0);   // uses a scoped DbContext
}

public class ExpiredCartsJob(IServiceScopeFactory scopes, ILogger<ExpiredCartsJob> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
        do
        {
            try
            {
                await using var scope = scopes.CreateAsyncScope();
                var cleaner = scope.ServiceProvider.GetRequiredService<ICartCleaner>();
                var deleted = await cleaner.DeleteExpiredAsync(stoppingToken);
                logger.LogInformation("Deleted {Count} expired carts", deleted);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Expired cart cleanup failed");
            }
        } while (await timer.WaitForNextTickAsync(stoppingToken));
    }
}

Pitfalls

  • Injecting scoped services (DbContext) directly into the hosted service constructor.
  • Unhandled exceptions in ExecuteAsync, which stop the service (and by default the host).
  • Running the same job on every replica; use a distributed lock or a dedicated worker deployment.

Variations

  • For durable, retryable jobs use a queue (Azure Service Bus, RabbitMQ) or a library like Hangfire or Quartz.NET.
  • Use Channel<T> with a background consumer for in-process work queues.

Further reading: Hosted services

Reference: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services

More ASP.NET Core recipes

Was this page helpful?