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.

Key takeaways
  • BackgroundService runs 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 ExecuteAsync loop 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 — BackgroundService alone doesn't give you those.

A basic periodic worker

csharp
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.

csharp
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.

Watch forRunning multiple instances of your app (for scale or availability) means multiple copies of every 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.

Need help with this in production?

astrivo helps teams design, build, and modernize systems like this. Let's talk about your context.