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.
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
services.AddHostedService<ExpiredTokenCleanupJob>();
Key rules for the loop
- New scope per tick —
BackgroundServiceis registered once for the app's lifetime, butDbContextand most repositories are scoped. Resolving them straight from the constructor would hand every tick the same, ever-growingDbContext. - 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
stoppingTokenintoTask.Delayand 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.