Articles › ASP.NET Core
Background Tasks in ASP.NET Core: BackgroundService, Queues and Scheduled Jobs
Run background work in ASP.NET Core: BackgroundService, scoped services in workers, PeriodicTimer jobs, Channel queues, graceful shutdown and Windows Services.
Not every task belongs in an HTTP request. Sending emails, generating reports, cleaning expired data, syncing with external systems, sending Telegram notifications — these should run in the background so users get fast responses and the work survives a slow third-party API. ASP.NET Core has a built-in hosting model for exactly this.
IHostedService and BackgroundService
Any class registered as an IHostedService is started when the app starts and stopped when it shuts down. BackgroundService is the convenient base class: you implement one method, ExecuteAsync.
public class HeartbeatWorker(ILogger<HeartbeatWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
logger.LogInformation("Worker alive at {Time}", DateTimeOffset.UtcNow);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}
builder.Services.AddHostedService<HeartbeatWorker>();
The stoppingToken is cancelled when the application shuts down — always pass it to delays and I/O calls so the worker stops promptly.
Scheduled jobs with PeriodicTimer
PeriodicTimer (.NET 6+) is the cleanest way to run something on an interval without drift and overlapping executions:
public class ExpiredSessionCleanup(IServiceScopeFactory scopeFactory, ILogger<ExpiredSessionCleanup> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
do
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var deleted = await db.Sessions
.Where(s => s.ExpiresAt < DateTime.UtcNow)
.ExecuteDeleteAsync(stoppingToken);
logger.LogInformation("Removed {Count} expired sessions", deleted);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Session cleanup failed"); // don't let one failure kill the worker
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
}
Two crucial details in this code:
- Scoped services need a scope. Hosted services are singletons.
DbContextis scoped, so injectIServiceScopeFactoryand create a scope per unit of work — never injectDbContextdirectly into the worker's constructor. - Catch exceptions inside the loop. An unhandled exception in
ExecuteAsyncstops the worker — and, by default since .NET 6, stops the whole application. Log and continue.
Running at a specific time
static TimeSpan DelayUntil(TimeOnly runAt, TimeZoneInfo zone)
{
var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, zone);
var next = now.Date + runAt.ToTimeSpan();
if (next <= now.DateTime) next = next.AddDays(1);
return next - now.DateTime;
}
// in ExecuteAsync: a daily report at 02:00 Tehran time
var tehran = TimeZoneInfo.FindSystemTimeZoneById("Iran Standard Time");
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(DelayUntil(new TimeOnly(2, 0), tehran), stoppingToken);
await GenerateDailyReportAsync(stoppingToken);
}
For complex schedules (cron expressions, retries, a dashboard, persistence across restarts), use a library such as Hangfire or Quartz.NET.
Background work queue with Channel
Sometimes a request should trigger work without waiting for it — e.g. "send a welcome email after registration". A bounded Channel<T> is a fast, thread-safe in-memory queue:
public interface IBackgroundQueue
{
ValueTask EnqueueAsync(Func<IServiceProvider, CancellationToken, Task> job, CancellationToken ct = default);
}
public class BackgroundQueue : IBackgroundQueue
{
private readonly Channel<Func<IServiceProvider, CancellationToken, Task>> _channel =
Channel.CreateBounded<Func<IServiceProvider, CancellationToken, Task>>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait
});
public ValueTask EnqueueAsync(Func<IServiceProvider, CancellationToken, Task> job, CancellationToken ct = default) =>
_channel.Writer.WriteAsync(job, ct);
public IAsyncEnumerable<Func<IServiceProvider, CancellationToken, Task>> ReadAllAsync(CancellationToken ct) =>
_channel.Reader.ReadAllAsync(ct);
}
public class QueueWorker(BackgroundQueue queue, IServiceScopeFactory scopes, ILogger<QueueWorker> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var job in queue.ReadAllAsync(stoppingToken))
{
try
{
await using var scope = scopes.CreateAsyncScope();
await job(scope.ServiceProvider, stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Background job failed");
}
}
}
}
// Registration
builder.Services.AddSingleton<BackgroundQueue>();
builder.Services.AddSingleton<IBackgroundQueue>(sp => sp.GetRequiredService<BackgroundQueue>());
builder.Services.AddHostedService<QueueWorker>();
// Usage in an endpoint
await queue.EnqueueAsync((sp, ct) =>
sp.GetRequiredService<IEmailSender>().SendAsync(user.Email, "Welcome!", ct));
return Results.Accepted();
Warning: An in-memory queue loses jobs if the process restarts. For work that must not be lost (payments, invoices), use a persistent queue: a database outbox table, Hangfire, RabbitMQ or Azure Service Bus.
Graceful shutdown
When the host stops (deployment, IIS recycle, container stop), it cancels stoppingToken and waits for workers to finish — 30 seconds by default since .NET 6:
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(45));
Design jobs to be idempotent (safe to run twice) and resumable, since a stop can always interrupt them.
Hosting considerations
- IIS — application pools can recycle or shut down when idle, stopping your workers. Disable idle timeout, set the start mode to
AlwaysRunningand enable preload — or move workers to a separate service. - Multiple instances — every instance runs every hosted service. A cleanup job running on three servers simultaneously may conflict. Use distributed locks, leader election, or run scheduled jobs in a single dedicated worker.
- Worker Service template — for background-only processes,
dotnet new workercreates a host without the web server. Withbuilder.Services.AddWindowsService()(orAddSystemd()) it runs as a Windows Service or Linux daemon — a great fit for bots and integration jobs.
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(o => o.ServiceName = "Portfolio Bot");
builder.Services.AddHostedService<TelegramBotWorker>();
builder.Build().Run();
Summary
Use BackgroundService for long-running or periodic work, PeriodicTimer for intervals, Channel<T> for in-process job queues, and scopes for every unit of work that needs DbContext or other scoped services. Catch exceptions inside loops, honor the stopping token, make jobs idempotent, and choose persistent queues or dedicated schedulers when jobs must survive restarts or run on a single node. Done right, background processing keeps your API fast and your system resilient.
FAQ
Why does my DbContext throw "Cannot consume scoped service"?
Hosted services are singletons. Inject IServiceScopeFactory and resolve DbContext inside a scope you create.
Should I use Task.Run in a controller for background work?
No. The work isn't tracked, exceptions are lost, and it can be killed when the request ends or the app recycles. Use a queue processed by a hosted service.
Hangfire or a hand-written BackgroundService?
Hand-written workers are perfect for simple loops. Hangfire or Quartz add persistence, retries, cron schedules and dashboards for serious job processing.