Background Services in .NET: Workers, Queues, and Scheduled Jobs
A practical guide to building long-running background processes in .NET — covering IHostedService, BackgroundService, queue processing, scheduled jobs, graceful shutdown, and scaling patterns for workers, email services, and job processors.
Table of Contents
- Introduction
- IHostedService: The Foundation
- BackgroundService: The Common Base Class
- Worker Service Projects
- Queue Processing Patterns
- Scheduled and Recurring Jobs
- Dependency Injection and Scoped Services
- Graceful Shutdown and Cancellation
- Error Handling and Resilience
- Scaling Out: Multiple Instances
- Observability
- Choosing the Right Tool
- Quick Reference Table
- Conclusion
Introduction
Not every unit of work in a .NET application should happen inside an HTTP request. Sending a confirmation email, processing an uploaded file, polling a message queue, or running a nightly cleanup job are all things that need to happen independently of any particular request-response cycle — sometimes continuously, sometimes on a schedule, sometimes triggered by an external event.
.NET's Generic Host provides a first-class abstraction for exactly this: hosted services that start when the application starts, run for as long as the application runs, and shut down cleanly when it stops — whether that application is a full ASP.NET Core web app or a small, standalone Worker Service with no web server at all.
public class EmailQueueWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await ProcessNextEmailAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
1. IHostedService: The Foundation
IHostedService is the base interface behind every background process in .NET — two methods, called once each, by the host itself:
public interface IHostedService
{
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
public class TimerBasedService : IHostedService, IDisposable
{
private Timer? _timer;
public Task StartAsync(CancellationToken cancellationToken)
{
_timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
return Task.CompletedTask;
}
private void DoWork(object? state)
{
Console.WriteLine($"Tick at {DateTime.UtcNow}");
}
public Task StopAsync(CancellationToken cancellationToken)
{
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
public void Dispose() => _timer?.Dispose();
}
Registration
var builder = WebApplication.CreateBuilder(args); // or Host.CreateApplicationBuilder(args) for non-web apps
builder.Services.AddHostedService<TimerBasedService>();
var app = builder.Build();
app.Run();
Multiple IHostedService implementations can be registered in the same app, and the host manages all of them together — starting them (in registration order) when the application starts and stopping them (in reverse order) during shutdown.
IHostedService is low-level and rarely implemented directly for anything beyond a Timer-driven callback — for actual asynchronous work loops, BackgroundService (next section) is almost always the better starting point.
2. BackgroundService: The Common Base Class
BackgroundService is an abstract base class the framework provides on top of IHostedService, purpose-built for long-running asynchronous loops.
public abstract class BackgroundService : IHostedService, IDisposable
{
protected abstract Task ExecuteAsync(CancellationToken stoppingToken);
// StartAsync/StopAsync are implemented for you, wiring into ExecuteAsync
}
You only need to override ExecuteAsync and write a loop:
public class QueueProcessorService : BackgroundService
{
private readonly ILogger<QueueProcessorService> _logger;
private readonly IMessageQueue _queue;
public QueueProcessorService(ILogger<QueueProcessorService> logger, IMessageQueue queue)
{
_logger = logger;
_queue = queue;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Queue processor starting.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
var message = await _queue.DequeueAsync(stoppingToken);
await ProcessMessageAsync(message, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break; // expected during shutdown — exit the loop cleanly
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing queue message.");
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); // brief backoff before retrying
}
}
_logger.LogInformation("Queue processor stopping.");
}
}
builder.Services.AddHostedService<QueueProcessorService>();
Key details worth internalizing
-
ExecuteAsyncruns on a background thread — the host calls it and doesn't await it inline during startup, so a long-running loop here doesn't block application startup. -
Always honor
stoppingToken— pass it into every awaitable call inside the loop (Task.Delay, database calls, HTTP calls). This is what lets the host shut the service down promptly rather than waiting for an in-progress operation with no way to cancel it. -
Catch
OperationCanceledExceptionseparately — whenstoppingTokenfires during shutdown, an in-flightawaitoften throws this exception; treat it as an expected exit signal, not an error to log as a failure. -
An unhandled exception escaping
ExecuteAsyncstops the service entirely and, depending onHostOptions.BackgroundServiceExceptionBehavior, can even bring down the whole host (see Section 8) — atry/catcharound the loop body, not just around individual operations, is essential.
3. Worker Service Projects
For applications that are purely background processing with no web server at all, .NET provides a dedicated Worker Service project template — a lighter-weight host than a full ASP.NET Core app.
dotnet new worker -n EmailProcessor
// Program.cs
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<EmailQueueWorker>();
builder.Services.AddSingleton<ISmtpClient, SmtpClient>();
var host = builder.Build();
host.Run();
This is the natural home for things like:
- A dedicated email-sending microservice consuming from a queue
- A file-processing worker watching a storage location
- A scheduled batch-job runner with no HTTP surface at all
Worker Service projects deploy the same way any .NET console app does — as a Windows Service, a systemd unit on Linux, or (very commonly today) a container running under Kubernetes/Docker Compose as its own independently-scaled deployment.
4. Queue Processing Patterns
In-memory channel-based queue (single-process only)
For a single-instance application that needs to decouple request handling from slower background work, System.Threading.Channels provides a fast, in-memory producer/consumer queue:
public interface IBackgroundTaskQueue
{
ValueTask QueueAsync(Func<CancellationToken, ValueTask> workItem);
ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken);
}
public class BackgroundTaskQueue : IBackgroundTaskQueue
{
private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
Channel.CreateBounded<Func<CancellationToken, ValueTask>>(100);
public ValueTask QueueAsync(Func<CancellationToken, ValueTask> workItem) =>
_queue.Writer.WriteAsync(workItem);
public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken) =>
_queue.Reader.ReadAsync(cancellationToken);
}
public class QueuedHostedService : BackgroundService
{
private readonly IBackgroundTaskQueue _taskQueue;
public QueuedHostedService(IBackgroundTaskQueue taskQueue) => _taskQueue = taskQueue;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var workItem = await _taskQueue.DequeueAsync(stoppingToken);
await workItem(stoppingToken);
}
}
}
// From a minimal API endpoint — enqueue work instead of blocking the request
app.MapPost("/reports", async (ReportRequest request, IBackgroundTaskQueue queue) =>
{
await queue.QueueAsync(async token => await GenerateReportAsync(request, token));
return Results.Accepted();
});
Important limitation: this queue lives entirely in process memory. If the app restarts or crashes, anything still queued is lost, and it doesn't work across multiple instances — for anything where losing a queued item is unacceptable, use a durable, external queue instead.
Durable external queues
For production systems where queued work must survive restarts and scale across multiple worker instances, use a real message broker:
- Azure Service Bus / Amazon SQS — managed, durable cloud queues with retry and dead-letter support built in.
- RabbitMQ — a widely used, self-hosted message broker with flexible routing.
- Kafka — for high-throughput event streaming rather than simple task queues.
public class ServiceBusQueueWorker : BackgroundService
{
private readonly ServiceBusProcessor _processor;
public ServiceBusQueueWorker(ServiceBusClient client)
{
_processor = client.CreateProcessor("email-queue", new ServiceBusProcessorOptions());
_processor.ProcessMessageAsync += HandleMessageAsync;
_processor.ProcessErrorAsync += HandleErrorAsync;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _processor.StartProcessingAsync(stoppingToken);
await Task.Delay(Timeout.Infinite, stoppingToken); // keep running until cancelled
}
private async Task HandleMessageAsync(ProcessMessageEventArgs args)
{
var email = args.Message.Body.ToObjectFromJson<EmailRequest>();
await SendEmailAsync(email);
await args.CompleteMessageAsync(args.Message);
}
private Task HandleErrorAsync(ProcessErrorEventArgs args)
{
_logger.LogError(args.Exception, "Service Bus processing error.");
return Task.CompletedTask;
}
}
The broker itself handles durability, at-least-once delivery, retries, and dead-lettering (routing permanently-failing messages to a separate queue for investigation) — the worker's job is just to process each message and acknowledge it.
5. Scheduled and Recurring Jobs
Simple periodic timer (built into the BCL)
.NET's PeriodicTimer (introduced in .NET 6) provides a clean, allocation-light way to run something on a fixed interval:
public class NightlyCleanupService : BackgroundService
{
private readonly PeriodicTimer _timer = new(TimeSpan.FromHours(24));
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (await _timer.WaitForNextTickAsync(stoppingToken))
{
await RunCleanupAsync(stoppingToken);
}
}
}
This is fine for simple, fixed intervals, but it doesn't understand cron expressions, specific times of day, or calendar-aware scheduling (e.g., "the first business day of the month") — for that, reach for a scheduling library.
Cron-style scheduling with a library
Hangfire and Quartz.NET are the two most widely used .NET job-scheduling libraries, and they solve different but overlapping problems.
Hangfire — persists jobs to a storage backend (SQL Server, Redis, etc.), giving you a dashboard, automatic retries, and durability across restarts with very little setup:
builder.Services.AddHangfire(config => config.UseSqlServerStorage(connectionString));
builder.Services.AddHangfireServer();
var app = builder.Build();
app.UseHangfireDashboard(); // visual dashboard at /hangfire
RecurringJob.AddOrUpdate<IReportService>(
"nightly-report",
service => service.GenerateNightlyReportAsync(),
Cron.Daily);
BackgroundJob.Enqueue<IEmailService>(service => service.SendWelcomeEmailAsync(userId));
Quartz.NET — a more fully-featured, cron-native scheduler with rich trigger types, often preferred when scheduling logic itself is complex (multiple overlapping schedules, misfire policies, clustering):
builder.Services.AddQuartz(q =>
{
var jobKey = new JobKey("NightlyReportJob");
q.AddJob<NightlyReportJob>(opts => opts.WithIdentity(jobKey));
q.AddTrigger(opts => opts
.ForJob(jobKey)
.WithIdentity("NightlyReportTrigger")
.WithCronSchedule("0 0 2 * * ?")); // 2:00 AM daily
});
builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
public class NightlyReportJob : IJob
{
private readonly IReportService _reportService;
public NightlyReportJob(IReportService reportService) => _reportService = reportService;
public async Task Execute(IJobExecutionContext context) =>
await _reportService.GenerateNightlyReportAsync();
}
Choosing between them
PeriodicTimer |
Hangfire | Quartz.NET | |
|---|---|---|---|
| Best for | Simple fixed intervals, single instance | Fire-and-forget/recurring jobs with a UI and minimal setup | Complex cron scheduling, clustering, fine-grained trigger control |
| Persistence | None — resets on restart | Built-in (SQL/Redis-backed) | Optional (in-memory or persistent job store) |
| Dashboard | None | Yes, built-in | Third-party/custom |
| Clustering support | No | Yes | Yes |
6. Dependency Injection and Scoped Services
BackgroundService implementations are typically registered as singletons (there's one instance for the app's lifetime), but many of the services they depend on — most notably DbContext — are registered as scoped, meaning they're meant to live for the duration of one logical operation, not the entire application lifetime.
Injecting a scoped service directly into a singleton's constructor is a common and serious mistake:
// ❌ Wrong: AppDbContext is scoped, but this class is a singleton
public class BadWorker : BackgroundService
{
private readonly AppDbContext _db; // captured once, reused forever — will misbehave
public BadWorker(AppDbContext db) => _db = db;
}
The fix: create a scope per unit of work
public class ReportWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public ReportWorker(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await ProcessPendingReportsAsync(db, stoppingToken);
} // scope (and the DbContext inside it) is disposed here
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}
Each loop iteration gets its own fresh scope — and therefore its own fresh DbContext — which is exactly the lifetime DbContext is designed for (it's not thread-safe and isn't meant to be reused indefinitely across unrelated units of work).
7. Graceful Shutdown and Cancellation
Why the stopping token matters
When the host begins shutting down (app stop, container SIGTERM, deployment rolling update), it signals cancellation via stoppingToken and then waits a configurable period for ExecuteAsync to actually return before forcibly terminating.
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(30); // how long to wait before giving up
});
Writing shutdown-friendly loops
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var item = await _queue.DequeueAsync(stoppingToken); // honors cancellation while waiting
await ProcessAsync(item, stoppingToken); // honors cancellation mid-processing
}
}
If a currently-processing item can't be safely interrupted mid-way (e.g., a partially-written file), design the unit of work to be small enough that "finish the current item, then stop" happens well within the shutdown timeout — rather than trying to hard-abort arbitrary in-progress work.
IHostApplicationLifetime for custom shutdown hooks
public class MyWorker : BackgroundService
{
private readonly IHostApplicationLifetime _lifetime;
public MyWorker(IHostApplicationLifetime lifetime) => _lifetime = lifetime;
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
_lifetime.ApplicationStopping.Register(() =>
{
_logger.LogInformation("Application is stopping — finishing current batch.");
});
// ...
return Task.CompletedTask;
}
}
8. Error Handling and Resilience
An unhandled exception can take down the whole host
By default (since .NET 6), if ExecuteAsync throws an unhandled exception, the host treats it as fatal and stops the entire application — not just that one service:
builder.Services.Configure<HostOptions>(options =>
{
// StopHost (default): an unhandled exception in any BackgroundService stops the whole app
// Ignore: log and continue running other services (rarely what you want silently)
options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost;
});
The practical takeaway: always wrap the loop body in try/catch inside ExecuteAsync itself, so a single failed item doesn't crash the whole process — let the catch log the error and continue the loop, rather than relying on host-level behavior as your error handling strategy.
Retry with backoff
private async Task ProcessWithRetryAsync(WorkItem item, CancellationToken ct)
{
const int maxAttempts = 3;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
await ProcessAsync(item, ct);
return;
}
catch (Exception ex) when (attempt < maxAttempts)
{
_logger.LogWarning(ex, "Attempt {Attempt} failed, retrying...", attempt);
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct); // exponential backoff
}
}
}
For anything beyond simple retry loops, the Polly library provides production-grade retry, circuit-breaker, and timeout policies that integrate cleanly with HttpClient and general async operations used inside background services.
Dead-lettering unrecoverable items
When an item fails every retry attempt, don't just drop it — route it somewhere for investigation (a dead-letter queue, a "failed jobs" table) so failures are visible and recoverable rather than silently lost.
9. Scaling Out: Multiple Instances
Running more than one instance of a worker (for throughput or high availability) introduces a coordination problem: if two instances both poll the same queue or run the same scheduled job, you risk duplicate processing.
Let the queue handle it
Durable message brokers (Service Bus, SQS, RabbitMQ) are built for this — each message is delivered to exactly one consumer among however many are competing for the queue, so scaling worker instances horizontally is often as simple as running more of them; the broker distributes work automatically.
Distributed locks for scheduled jobs
Recurring jobs are trickier — if a cron-scheduled cleanup job runs on every instance at 2 AM, you'll get duplicate execution unless something coordinates it:
- Hangfire and Quartz.NET both support clustering, where instances coordinate via the shared storage backend so a recurring job runs on exactly one node even when the app is scaled to many.
- For custom jobs, a distributed lock (via Redis, a database row with an expiry, or a cloud-native lease mechanism) ensures only one instance actually executes the job body, while the others detect the lock and skip.
await using var @lock = await _distributedLockProvider.TryAcquireAsync("nightly-report-lock", TimeSpan.FromMinutes(10));
if (@lock is null)
{
_logger.LogInformation("Another instance is already running this job — skipping.");
return;
}
await RunNightlyReportAsync();
10. Observability
Background services run with no HTTP request to attach logs or traces to by default, so deliberate instrumentation matters more here than in request-driven code.
public class QueueProcessorService : BackgroundService
{
private readonly ILogger<QueueProcessorService> _logger;
private readonly IMeterFactory _meterFactory;
private readonly Counter<int> _processedCounter;
public QueueProcessorService(ILogger<QueueProcessorService> logger, IMeterFactory meterFactory)
{
_logger = logger;
var meter = meterFactory.Create("QueueProcessor");
_processedCounter = meter.CreateCounter<int>("messages_processed");
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var activity = MyActivitySource.StartActivity("ProcessMessage");
var message = await _queue.DequeueAsync(stoppingToken);
await ProcessMessageAsync(message, stoppingToken);
_processedCounter.Add(1);
}
}
}
Health checks for background services
Expose whether a worker is actually healthy — not just "the process is running," but "it's making progress":
builder.Services.AddHostedService<QueueProcessorService>();
builder.Services.AddSingleton<QueueProcessorHealthCheck>();
builder.Services.AddHealthChecks().AddCheck<QueueProcessorHealthCheck>("queue-processor");
app.MapHealthChecks("/health");
A common pattern: the worker updates a shared "last successful run" timestamp, and the health check fails if that timestamp is older than expected — catching a silently-stuck worker that's still technically "running" but not actually doing anything.
11. Choosing the Right Tool
| Scenario | Recommended approach |
|---|---|
| Fire-and-forget work triggered by a request, single instance, OK to lose on crash | In-memory Channel<T> + BackgroundService
|
| Durable task queue, must survive restarts, multiple workers | Azure Service Bus / SQS / RabbitMQ + BackgroundService
|
| Simple fixed-interval polling |
PeriodicTimer inside BackgroundService
|
| Cron-style recurring jobs with a dashboard and minimal setup | Hangfire |
| Complex scheduling logic, clustering, fine trigger control | Quartz.NET |
| Standalone process, no web server needed at all | Worker Service project template |
| High-throughput event streaming (not simple task queuing) | Kafka + a dedicated consumer worker |
Quick Reference Table
| Concept | Purpose |
|---|---|
IHostedService |
Low-level start/stop hook into the host lifecycle |
BackgroundService |
Base class for long-running async loops |
| Worker Service template | Standalone host with no web server |
Channel<T> |
Fast in-memory producer/consumer queue (non-durable) |
| Durable message broker | Persistent, multi-instance-safe queue (Service Bus/SQS/RabbitMQ) |
PeriodicTimer |
Simple fixed-interval scheduling |
| Hangfire / Quartz.NET | Cron-style recurring job scheduling with persistence |
IServiceScopeFactory |
Creates a DI scope per unit of work for scoped dependencies like DbContext
|
stoppingToken |
Signals graceful shutdown; must be honored throughout the loop |
BackgroundServiceExceptionBehavior |
Controls whether an unhandled exception stops the whole host |
| Distributed lock | Prevents duplicate execution of a scheduled job across scaled instances |
Conclusion
Background services are how .NET applications do the work that shouldn't block a request-response cycle: processing queues, sending emails, running scheduled jobs, and handling anything that needs to keep running independently of any particular client interaction. BackgroundService provides the right shape for almost all of it — a long-running async loop that respects cancellation — while the surrounding ecosystem (Channels for simple in-memory queuing, durable brokers for anything that must survive a crash, Hangfire or Quartz.NET for scheduling) fills in the parts that a hand-rolled loop shouldn't have to reinvent.
The details that separate a background service that works from one that quietly causes production incidents are almost always the same handful of things: honoring the cancellation token so shutdown is actually graceful, creating a fresh DI scope per unit of work instead of capturing a scoped service in a singleton, catching exceptions inside the loop rather than letting one bad item take down the whole host, and instrumenting enough that "is this worker actually making progress" is answerable without guessing.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the scoped-DbContext-in-a-singleton bug that taught you to respect IServiceScopeFactory.
Top comments (0)