Background services in .NET run work outside the request path: polling queues, refreshing caches, sending emails, processing uploads or running entire worker processes. This guide is for developers who build ASP.NET Core apps or standalone workers and want them to behave well in production. You will learn how IHostedService and BackgroundService fit into the host lifecycle, how to shut down gracefully, how to schedule work with PeriodicTimer, how to queue work with System.Threading.Channels, how to run workers as Windows Services or systemd units, and when a library such as Hangfire or Quartz.NET is the better tool.
What Are Background Services in .NET?#
A background service is a class the .NET Generic Host starts when the application starts and stops when it shuts down. The contract is IHostedService, which has two methods: StartAsync and StopAsync. The framework also provides BackgroundService, an abstract base class that implements that contract for long-running loops; you override a single ExecuteAsync(CancellationToken) method.
Hosted services can live inside an ASP.NET Core application, sharing its DI container, configuration and logging, or inside a dedicated worker process created from the dotnet new worker template. The worker template uses the Microsoft.NET.Sdk.Worker SDK and produces a plain console process with no HTTP server, which suits queue consumers, schedulers and integration jobs that should scale and deploy independently of the web tier.
Both hosting styles share the same building blocks described in the Generic Host guide, so everything in this article applies to web apps and workers alike.
How Hosted Services Work#
Understanding the lifecycle prevents most background-service bugs.
Startup. When the host starts, it calls StartAsync on every registered hosted service, in registration order and one after another by default. Since .NET 8, IHostedLifecycleService adds StartingAsync and StartedAsync hooks around that call, and HostOptions.ServicesStartConcurrently lets services start in parallel.
Execution. BackgroundService.StartAsync launches ExecuteAsync and returns without waiting for it to finish. .NET 10 changed an important detail here: all of ExecuteAsync now runs on a background thread. On .NET 8 and 9, the synchronous code before the first await ran on the startup thread and blocked every service registered after it, a common source of mysteriously slow startups. If you relied on that ordering, move blocking initialization into the constructor, an overridden StartAsync that calls base.StartAsync, or an IHostedLifecycleService.
Shutdown. On Ctrl+C, SIGTERM (for example docker stop or a Kubernetes pod termination) or a call to IHostApplicationLifetime.StopApplication(), the host signals ApplicationStopping and calls StopAsync on each service, in reverse order by default. BackgroundService.StopAsync cancels the stoppingToken passed to ExecuteAsync and waits for the method to finish, but only until the host's shutdown timeout expires. HostOptions.ShutdownTimeout defaults to 30 seconds.
Failure. If ExecuteAsync throws an unhandled exception, the host applies HostOptions.BackgroundServiceExceptionBehavior. Since .NET 6 the default is StopHost, which logs the error and stops the application; the alternative, Ignore, keeps the process running without the failed service, which is how "zombie" workers happened in earlier versions.
Getting Started with the Worker Template#
Running dotnet new worker -n OrderWorker creates a project whose Program.cs builds a host and registers one hosted service. Stripped to the essentials, it looks like this:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
using var host = builder.Build();
await host.RunAsync();
public sealed class Worker(ILogger<Worker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
logger.LogInformation("Worker running at {Time}", DateTimeOffset.UtcNow);
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}
}AddHostedService<T>() registers the service as a singleton. Pass the stoppingToken to every asynchronous call so that shutdown interrupts waits immediately instead of after the next iteration. When cancellation interrupts Task.Delay, it throws OperationCanceledException; the host treats that as a normal stop when shutdown has been requested.
IHostedService vs BackgroundService#
Use BackgroundService for work that runs for the lifetime of the application: loops, consumers and schedulers. Implement IHostedService directly for short, bounded tasks tied to startup or shutdown, such as warming a cache, validating external dependencies or flushing buffers on exit.
public sealed class CatalogWarmup(IServiceScopeFactory scopeFactory, ILogger<CatalogWarmup> logger)
: IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
// Awaited during host startup, so keep it short and honor the token.
await using var scope = scopeFactory.CreateAsyncScope();
var catalog = scope.ServiceProvider.GetRequiredService<IProductCatalog>();
var count = await catalog.WarmUpAsync(cancellationToken);
logger.LogInformation("Warmed {Count} catalog entries", count);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}Because the host awaits StartAsync, a slow implementation delays startup for everything registered after it, and an exception fails startup entirely. That is desirable for a critical check and harmful for optional work, which belongs in a BackgroundService instead.
Graceful Shutdown and StopAsync#
Graceful shutdown is a contract between your code, the host and the platform. The platform sends a termination signal and waits a limited time before killing the process: Kubernetes waits 30 seconds by default (terminationGracePeriodSeconds), and docker stop waits 10 seconds. The host must finish within that window, which means HostOptions.ShutdownTimeout should be shorter than the platform's grace period, and your services must react to cancellation promptly.
builder.Services.Configure<HostOptions>(options =>
{
// Leave headroom below the orchestrator's grace period (Kubernetes default: 30 s).
options.ShutdownTimeout = TimeSpan.FromSeconds(25);
options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost;
// .NET 8+: start and stop independent services in parallel.
options.ServicesStartConcurrently = true;
options.ServicesStopConcurrently = true;
});Design each unit of work so that it is either quick to finish or safe to abandon and retry. Commit progress in small batches, make handlers idempotent, and avoid starting new work once stoppingToken is canceled. Work that is still running when the timeout expires is abandoned when the process exits, so anything that must not be lost should live in durable storage, not in memory.
Scheduled Work with PeriodicTimer#
PeriodicTimer (since .NET 6) is the natural fit for recurring work inside a BackgroundService. Unlike System.Threading.Timer, whose callbacks can overlap when an execution runs longer than the interval, PeriodicTimer is awaited by a single consumer, so iterations never overlap; ticks that occur while work is running are coalesced into one. Since .NET 8 it accepts a TimeProvider, which lets tests advance time with FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package.
public sealed class PriceCacheRefresher(
IServiceScopeFactory scopeFactory, TimeProvider clock, ILogger<PriceCacheRefresher> logger)
: BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(5);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(Interval, clock);
do
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var prices = scope.ServiceProvider.GetRequiredService<IPriceCache>();
await prices.RefreshAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// One failed run must not kill the service; log and wait for the next tick.
logger.LogError(ex, "Price refresh failed; next attempt in {Interval}", Interval);
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
}The do/while shape runs the first refresh immediately, then once per interval. Register TimeProvider.System as a singleton, for example with builder.Services.TryAddSingleton(TimeProvider.System), so the constructor can resolve it. Note that every instance of a scaled-out application runs this loop; if a job must run on exactly one node, you need a distributed lock or a scheduler with clustering, discussed later.
Consuming Scoped Services in a Singleton#
Hosted services are singletons, but most application services, such as an EF Core DbContext, are scoped. Injecting a scoped service into a hosted service's constructor creates a captive dependency: one instance lives for the whole process and is shared across iterations. In the Development environment, scope validation turns this into a startup error.
The fix, used in the samples above, is to inject IServiceScopeFactory and create an async scope per unit of work with CreateAsyncScope(), disposing it with await using. Each iteration then gets fresh scoped instances that are disposed correctly, including services that implement only IAsyncDisposable. The dependency injection guide explains lifetimes and scope validation in detail.
Queued Background Work with System.Threading.Channels#
A common requirement is to accept a request quickly and do the slow part later, such as generating thumbnails or sending notifications. System.Threading.Channels provides a fast, async-friendly producer/consumer queue for exactly this. A bounded channel adds backpressure: with BoundedChannelFullMode.Wait, producers wait for space instead of letting memory grow without limit.
public sealed record ThumbnailRequest(Guid ImageId, string BlobPath);
public sealed class ThumbnailQueue
{
private readonly Channel<ThumbnailRequest> _channel = Channel.CreateBounded<ThumbnailRequest>(
new BoundedChannelOptions(capacity: 500) { FullMode = BoundedChannelFullMode.Wait });
public ValueTask EnqueueAsync(ThumbnailRequest request, CancellationToken ct) =>
_channel.Writer.WriteAsync(request, ct);
public IAsyncEnumerable<ThumbnailRequest> ReadAllAsync(CancellationToken ct) =>
_channel.Reader.ReadAllAsync(ct);
}
// Program.cs
builder.Services.AddSingleton<ThumbnailQueue>();
builder.Services.AddHostedService<ThumbnailWorker>();
app.MapPost("/images/{id:guid}/thumbnail",
async (Guid id, ThumbnailQueue queue, CancellationToken ct) =>
{
await queue.EnqueueAsync(new ThumbnailRequest(id, $"images/{id}"), ct);
return Results.Accepted();
});The consumer reads from the channel and processes items with bounded parallelism, creating a DI scope per item:
public sealed class ThumbnailWorker(
ThumbnailQueue queue, IServiceScopeFactory scopeFactory, ILogger<ThumbnailWorker> logger)
: BackgroundService
{
protected override Task ExecuteAsync(CancellationToken stoppingToken) =>
Parallel.ForEachAsync(
queue.ReadAllAsync(stoppingToken),
new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = stoppingToken },
async (request, ct) =>
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var generator = scope.ServiceProvider.GetRequiredService<IThumbnailGenerator>();
await generator.GenerateAsync(request, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Thumbnail failed for image {ImageId}", request.ImageId);
}
});
}Queue typed messages rather than delegates. A queued lambda can easily capture request-scoped objects, such as a DbContext or HttpContext, that are disposed by the time the worker runs it. With a message type, the worker resolves everything it needs from its own scope.
Know the limits of this pattern. An in-memory channel lives inside one process: items are lost if the process crashes or is redeployed, and other instances cannot help drain the queue. Use it for work that is cheap to lose or easy to regenerate. For work that must survive restarts, use a durable queue such as Azure Service Bus or RabbitMQ, as described in the messaging guide. If some items are more urgent than others, .NET 9 added Channel.CreateUnboundedPrioritized<T>(), which orders items with an IComparer<T> you supply through UnboundedPrioritizedChannelOptions<T>.
Exception Handling and BackgroundServiceExceptionBehavior#
Decide explicitly which failures a worker should survive and which should stop the process. Transient failures, such as a timeout calling a dependency, should be caught inside the loop, logged and retried with a delay. Unrecoverable failures, such as invalid configuration or a poison state, should stop the process with a non-zero exit code, so that the service manager or orchestrator restarts it and your alerts fire.
public sealed class OrderImportWorker(
IOrderImporter importer, IHostApplicationLifetime lifetime, ILogger<OrderImportWorker> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await importer.ImportNextBatchAsync(stoppingToken);
}
catch (TransientImportException ex)
{
logger.LogWarning(ex, "Import failed; retrying in 30 seconds");
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Normal shutdown.
}
catch (Exception ex)
{
logger.LogCritical(ex, "Order import stopped unexpectedly");
Environment.ExitCode = 1; // Surfaced to systemd, Kubernetes or a supervisor
lifetime.StopApplication();
}
}
}Keep the default StopHost behavior. Ignore hides failures: the process stays up, health checks may still pass and nothing processes the work. For Windows Services, Microsoft's tutorial goes further and calls Environment.Exit with a non-zero code in the catch block, because the Service Control Manager's recovery actions, such as restarting the service, apply when a service fails rather than when it stops cleanly.
Running as a Windows Service or systemd Unit#
Workers often run outside containers, managed by the operating system's service manager. Two packages integrate the host with them: Microsoft.Extensions.Hosting.WindowsServices and Microsoft.Extensions.Hosting.Systemd. Both registration calls detect their environment and do nothing otherwise, so one build can run as a console app during development, a Windows Service on Windows servers and a systemd unit on Linux:
var builder = Host.CreateApplicationBuilder(args);
// Each call is a no-op unless the process really runs under that service manager.
builder.Services.AddWindowsService(options => options.ServiceName = "Order Worker");
builder.Services.AddSystemd();
builder.Services.AddHostedService<OrderImportWorker>();
await builder.Build().RunAsync();AddWindowsService switches the host to the Windows Service lifetime and enables Event Log logging with the application name as the default source. The default host builder also avoids using C:\Windows\System32, the working directory of Windows Services, as its content root, so appsettings.json next to the executable is still found. Publish for the target runtime and register the service with sc.exe:
dotnet publish -c Release -r win-x64 -o C:\Services\OrderWorker
sc.exe create "Order Worker" binpath= "C:\Services\OrderWorker\OrderWorker.exe" start= auto
sc.exe failure "Order Worker" reset= 86400 actions= restart/60000/restart/60000
sc.exe start "Order Worker"On Linux, AddSystemd switches to the systemd lifetime, sends readiness and stopping notifications, and formats console logs for the journal. Use Type=notify so systemd considers the service started only when the host reports it is ready, and give systemd a stop timeout longer than HostOptions.ShutdownTimeout:
[Unit]
Description=Order import worker
Wants=network-online.target
After=network-online.target
[Service]
Type=notify
ExecStart=/opt/order-worker/OrderWorker
WorkingDirectory=/opt/order-worker
User=orderworker
Environment=DOTNET_ENVIRONMENT=Production
Restart=on-failure
RestartSec=10
TimeoutStopSec=45
[Install]
WantedBy=multi-user.targetIn containers, neither package is needed: the default console lifetime already handles SIGTERM, and the orchestrator restarts failed containers. The Kubernetes guide covers probes and termination settings for workers.
When to Use Hangfire or Quartz.NET#
Hosted services are deliberately simple: they have no persistence, no retry history, no dashboard and no coordination between instances. When you need those capabilities, adopt a library or a platform service rather than rebuilding them.
| Option | Persistence | Scheduling model | Multiple instances | Best for |
|---|---|---|---|---|
BackgroundService with PeriodicTimer | None | Fixed intervals | Runs on every instance | Cache refresh, polling, housekeeping |
| Channels queue in a hosted service | None; lost on restart | Immediate, in-process | Each instance has its own queue | Offloading cheap, non-critical work from requests |
| Hangfire (LGPL-3.0, commercial Pro) | Required storage such as SQL Server | Fire-and-forget, delayed, recurring (cron) | Shared storage, many servers | Durable jobs with automatic retries and a built-in dashboard |
| Quartz.NET (Apache-2.0) | Optional persistent job store | Cron, calendars, time zones, misfire policies | Clustering through a shared database | Complex schedules that must run on exactly one node |
| Message broker (Service Bus, RabbitMQ) | Durable broker | Immediate or scheduled messages | Competing consumers | Cross-service workflows and high throughput |
Choose Hangfire when the problem is jobs: you want to enqueue work from anywhere in the code, retry failures automatically, and let operators inspect and requeue jobs from a dashboard. Choose Quartz.NET when the problem is schedules: business calendars, per-trigger time zones, misfire handling and a guarantee that a clustered job fires on one node. Note that Quartz.NET 4.x targets .NET 10 only and changed IJob.Execute to return ValueTask; applications on .NET 8 or 9 stay on the 3.x line.
// Quartz.NET 4.x on .NET 10: jobs are resolved from DI, schedules are declared at startup
builder.AddQuartz(q =>
{
q.ScheduleJob<NightlyReportJob>(trigger => trigger
.WithIdentity("nightly-report")
.WithCronSchedule("0 0 2 * * ?")); // 02:00 every day (Quartz cron includes seconds)
});
builder.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
public sealed class NightlyReportJob(IReportService reports) : IJob
{
public async ValueTask Execute(
IJobExecutionContext context, CancellationToken cancellationToken)
{
await reports.GenerateDailyAsync(cancellationToken);
}
}Also consider platform schedulers. Azure Functions timer triggers, Azure Container Apps jobs and Kubernetes CronJobs run scheduled work without a long-lived process, which is often cheaper and simpler for jobs that run a few times a day; see the Azure Functions guide.
Best Practices#
- Honor the stopping token everywhere and pass it to every async call.
- Create a DI scope per unit of work with
IServiceScopeFactory.CreateAsyncScope(). - Catch transient errors inside the loop and let unrecoverable errors stop the host with a non-zero exit code.
- Keep
ShutdownTimeoutbelow the platform's grace period and make work idempotent so interrupted items can be retried. - Prefer
PeriodicTimeroverSystem.Threading.Timerfor async recurring work, and injectTimeProviderfor testability. - Use bounded channels to apply backpressure, and queue typed messages rather than closures.
- Expose health and progress: log iterations with structured fields, emit metrics and add a health check that reports when the loop last succeeded.
- Move critical work to durable infrastructure such as a broker, Hangfire or Quartz.NET with a persistent store.
Common Pitfalls#
- Blocking in
ExecuteAsyncbefore the first await on .NET 8 and 9, which stalls startup; .NET 10 runs the whole method in the background. - Injecting scoped services into hosted services, creating captive dependencies.
- Swallowing every exception, including
OperationCanceledException, so shutdown hangs until the timeout. - Setting
BackgroundServiceExceptionBehavior.Ignore, which leaves a running process that does nothing. - Assuming one instance. Scaling out an app multiplies every timer-based job.
- Treating an in-memory queue as durable, then losing work on every deployment.
- Calling
Task.Runfire-and-forget from controllers instead of a queue, which loses exceptions and ignores shutdown.
Frequently Asked Questions#
What is the difference between IHostedService and BackgroundService?#
IHostedService is the interface the host calls at startup and shutdown through StartAsync and StopAsync. BackgroundService is an abstract class implementing it for long-running work: you override ExecuteAsync, and the base class handles starting the task and cancelling it on shutdown. Use IHostedService directly for short startup or shutdown tasks.
How do I use a DbContext in a BackgroundService?#
Inject IServiceScopeFactory, create a scope with CreateAsyncScope() for each unit of work, resolve the DbContext from scope.ServiceProvider and dispose the scope with await using. Alternatively, register AddDbContextFactory and create a short-lived context per operation.
What happens when a BackgroundService throws an exception?#
Since .NET 6, the default BackgroundServiceExceptionBehavior.StopHost logs the exception and stops the whole host. You can switch to Ignore, but that leaves the process running without the failed service. Catch expected transient errors inside your loop and let unexpected ones stop the process.
Should I use PeriodicTimer or Task.Delay in a worker loop?#
PeriodicTimer keeps a steady cadence regardless of how long each iteration takes and never runs iterations concurrently, while Task.Delay in a loop waits a fixed time after each iteration, so the schedule drifts. Both honor cancellation tokens, and both accept a TimeProvider for testing.
When should I choose Hangfire or Quartz.NET over hosted services?#
Choose them when work must survive restarts, needs retries and visibility, or must run on exactly one node of a cluster. Hangfire focuses on durable job processing with a dashboard, while Quartz.NET focuses on rich scheduling and clustering. For simple in-process loops, a BackgroundService is enough.
Summary#
- Hosted services start and stop with the Generic Host;
BackgroundServiceis the base class for long-running loops. - .NET 10 runs all of
ExecuteAsyncon a background thread, andHostOptionscontrols shutdown timeout, failure behavior and concurrent start and stop. - Use
PeriodicTimerfor scheduled work, bounded channels for in-process queues and a DI scope per unit of work. - Stop the process with a failure exit code on unrecoverable errors, and run workers as Windows Services or systemd units with the dedicated hosting packages.
- Reach for Hangfire, Quartz.NET, a message broker or a platform scheduler when work must be durable, coordinated or visible.