BackgroundService covers more ground than most teams give it credit for: periodic jobs, queued work offloaded from a web request, long-running processors. You don't need a separate scheduler or a new deployment target until you actually outgrow it — and most services don't.
BackgroundServiceruns inside the same generic host as your web app, sharing its DI container, configuration, and lifetime — no separate process needed for most background work.- Wrap your
ExecuteAsyncloop body in try/catch and log failures explicitly — an unhandled exception in a hosted service can crash the entire host by default. - A
Channel<T>-backed queue is the standard pattern for offloading work from a web request without blocking the response. - Reach for Quartz.NET or Hangfire only once you need cron-like scheduling, persisted job state, or retries with backoff —
BackgroundServicealone doesn't give you those.
A basic periodic worker
public class OrderExpiryWorker(IServiceScopeFactory scopeFactory, ILogger<OrderExpiryWorker> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
using var scope = scopeFactory.CreateScope();
var orders = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
await orders.ExpireStaleDraftsAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Order expiry sweep failed");
}
}
}
}
// Program.cs
builder.Services.AddHostedService<OrderExpiryWorker>();
BackgroundService is a singleton, so it can't inject scoped services (like a DbContext) directly into its constructor. Create a scope per iteration instead — shown above — so each sweep gets its own scoped lifetime, the same as a normal request would.
Offloading work from a web request
For work that shouldn't block an HTTP response — sending a notification, generating a report — queue it in-process and let a hosted service drain the queue.
public interface IBackgroundTaskQueue
{
ValueTask QueueAsync(Func<IServiceProvider, CancellationToken, Task> workItem);
ValueTask<Func<IServiceProvider, CancellationToken, Task>> DequeueAsync(CancellationToken ct);
}
public class BackgroundTaskQueue : IBackgroundTaskQueue
{
private readonly Channel<Func<IServiceProvider, CancellationToken, Task>> _channel =
Channel.CreateBounded<Func<IServiceProvider, CancellationToken, Task>>(capacity: 200);
public ValueTask QueueAsync(Func<IServiceProvider, CancellationToken, Task> workItem) =>
_channel.Writer.WriteAsync(workItem);
public ValueTask<Func<IServiceProvider, CancellationToken, Task>> DequeueAsync(CancellationToken ct) =>
_channel.Reader.ReadAsync(ct);
}
public class QueuedHostedService(IBackgroundTaskQueue queue, IServiceProvider services) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var workItem = await queue.DequeueAsync(stoppingToken);
await workItem(services, stoppingToken);
}
}
}
A controller or minimal API endpoint calls queue.QueueAsync(...) and returns immediately; the hosted service processes items as capacity allows. The bounded channel applies natural backpressure — if the queue fills up, writers wait instead of piling up unbounded work in memory.
Graceful shutdown
Respect the CancellationToken passed into ExecuteAsync — it's signaled when the host begins shutting down. Long-running work should check it periodically and exit cleanly rather than being killed mid-operation.
BackgroundService run too. For work that must run exactly once — a nightly report, a single cron-like job — add a distributed lock (a database row with an expiry, or a Redis-based lock) before doing the work, or move that specific job to a dedicated single-instance deployment.When to graduate to Quartz.NET or Hangfire
BackgroundService is enough for continuous loops and queue-draining. Reach for Quartz.NET or Hangfire once you need cron-style scheduling expressions, job state persisted across restarts, automatic retries with backoff, or a dashboard for operators to see what ran and what failed — those are real features those libraries provide that BackgroundService deliberately doesn't.