Background Jobs

Recurring work that shouldn't block a request — cleaning up expired tokens, sending scheduled reminders, refreshing a cache — runs as a BackgroundService registered alongside everything else in DI, with its own scope per tick since it lives longer than any single request.

The base shape

BackgroundService gives you a single ExecuteAsync loop that starts with the app and is cancelled cleanly on shutdown via the token it's handed.

BackgroundJobs/ExpiredTokenCleanupJob.cs
public class ExpiredTokenCleanupJob : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<ExpiredTokenCleanupJob> _logger;
    private readonly TimeSpan _interval = TimeSpan.FromHours(1);

    public ExpiredTokenCleanupJob(IServiceScopeFactory scopeFactory, ILogger<ExpiredTokenCleanupJob> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                // BackgroundService is a singleton — DbContext and scoped
                // repositories are not, so a fresh scope is created per tick.
                using var scope = _scopeFactory.CreateScope();
                var repository = scope.ServiceProvider.GetRequiredService<ITokenRepository>();

                var removed = await repository.DeleteExpiredAsync(DateTime.UtcNow);
                if (removed > 0)
                    _logger.LogInformation("Cleaned up {Count} expired tokens", removed);
            }
            catch (Exception ex)
            {
                // never let one failed tick kill the loop
                _logger.LogError(ex, "Token cleanup job failed");
            }

            await Task.Delay(_interval, stoppingToken);
        }
    }
}

Registration

Dependencies.cs
services.AddHostedService<ExpiredTokenCleanupJob>();

Key rules for the loop

  • New scope per tick — BackgroundService is registered once for the app's lifetime, but DbContext and most repositories are scoped. Resolving them straight from the constructor would hand every tick the same, ever-growing DbContext.
  • Catch inside the loop, not around it — an uncaught exception stops the whole loop permanently until the app restarts. Log and continue instead.
  • Respect the cancellation token — pass stoppingToken into Task.Delay and any async calls so shutdown doesn't hang waiting for a job mid-sleep.

When you outgrow this

A single Task.Delay loop is enough for "run roughly every N minutes" jobs in a single-instance deployment. Reach for something heavier — Quartz.NET for cron-style scheduling, or Hangfire for persisted/retryable jobs with a dashboard — once you need exact scheduling, jobs that survive a restart mid-run, or multiple instances that shouldn't all run the same job at once.