Background Tasks with Hosted Services in ASP.NET Core: A .NET 10 Deep Dive
How ASP.NET Core hosted services really work in .NET 10: IHostedService, BackgroundService and IHostedLifecycleService, timed work with PeriodicTimer, queued work with Channel<T>, exceptions, graceful shutdown, scaling out, health checks, testing, and when to switch to a real job scheduler.

Almost every real ASP.NET Core app needs to do work that doesn’t belong inside an HTTP request. Some examples:
- Sending the order confirmation email after the customer clicks “Buy”
- Deleting abandoned shopping carts every few minutes
- Warming a cache before the first request arrives
- Processing messages from a queue
- Generating a nightly report
You could open a separate console app for each of these. Usually you don’t need to. ASP.NET Core ships with a built-in way to run background work inside the same process as your web app: hosted services.
Hosted services look simple. You implement an interface, register it with AddHostedService, and your code runs. But a lot of production bugs live in the details:
- A service that silently stops running and nobody notices for a week
- An app that takes 40 seconds to start because one service blocks startup
- A
DbContextthat throwsObjectDisposedExceptionat 3 a.m. - Jobs that disappear on every deployment
- A cleanup task that runs five times because you scaled out to five instances
This post goes deep. We’ll look at how the host actually runs your services (including what changed in .NET 10), the three interfaces you can use, the patterns for timed work and queued work, exception handling, graceful shutdown, scaling out, observability, testing, and when you should stop using hosted services and pick a real job scheduler instead.
There are diagrams throughout, because the lifecycle is much easier to understand when you can see it.
The big picture: what is a hosted service?
Every ASP.NET Core app runs on the .NET Generic Host. The host is the object that owns your app’s dependency injection container, configuration, logging, and lifetime. When you call app.Run(), the host starts, runs until it receives a shutdown signal, and then stops.
A hosted service is any class that implements IHostedService and is registered with the host. The host calls StartAsync on each one when the app starts and StopAsync on each one when the app shuts down.
Here’s the important part: the web server itself is also a hosted service. Kestrel is started by an internal hosted service called GenericWebHostService. Your background services sit right next to it, as equals.
flowchart TB
subgraph Host[".NET Generic Host (one process)"]
direction TB
DI["DI container<br/>configuration<br/>logging"]
subgraph HS["Registered IHostedService instances"]
direction LR
A["CatalogCacheWarmup<br/>(IHostedService)"]
B["ExpiredCartCleanup<br/>(BackgroundService)"]
C["OrderConfirmationProcessor<br/>(BackgroundService)"]
W["GenericWebHostService<br/>(runs Kestrel)"]
end
L["IHostApplicationLifetime<br/>Started / Stopping / Stopped"]
end
Signal["Ctrl+C / SIGTERM /<br/>docker stop / Kubernetes"] --> L
L --> HS
DI --> HS
W --> HTTP["HTTP requests"]
Three types are involved, and you’ll use all three in this post:
| Type | What it is | When to use it |
|---|---|---|
IHostedService |
The base contract: StartAsync and StopAsync |
One-shot startup or shutdown work |
BackgroundService |
An abstract class that implements IHostedService for you and gives you one method, ExecuteAsync |
Long-running loops: timers, queue consumers, pollers |
IHostedLifecycleService |
Extends IHostedService with four extra hooks: StartingAsync, StartedAsync, StoppingAsync, StoppedAsync (added in .NET 8) |
Precise control over when during startup or shutdown your code runs |
Let’s take them one at a time.
IHostedService: the basic contract
The interface has two methods:
public interface IHostedService
{
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
The host calls them like this:
StartAsyncruns when the app starts, before Kestrel starts accepting requests. Services start one after the other, in the order they were registered, and the host waits for eachStartAsyncto finish before calling the next one.StopAsyncruns during graceful shutdown, in reverse registration order.
That first point is the one people miss. Because StartAsync runs before the web server starts, a slow StartAsync delays your whole app. The health probe fails, the load balancer marks the instance unhealthy, and in Kubernetes the pod might be restarted before it ever serves a request.
sequenceDiagram
autonumber
participant Main as Program.cs<br/>app.Run()
participant Host as Generic Host
participant S1 as CatalogCacheWarmup
participant S2 as ExpiredCartCleanup
participant Web as GenericWebHostService<br/>(Kestrel)
Main->>Host: StartAsync()
Host->>S1: StartAsync()
Note over S1: Loads categories<br/>from the database
S1-->>Host: completed
Host->>S2: StartAsync()
S2-->>Host: completed (returns immediately)
Host->>Web: StartAsync()
Note over Web: Kestrel binds ports<br/>and starts listening
Web-->>Host: completed
Host-->>Main: ApplicationStarted
Note over Main,Web: Only now can the app serve HTTP requests
That ordering is sometimes exactly what you want. If you need a cache warmed before any request arrives, a plain IHostedService gives you that guarantee:
public sealed class CatalogCacheWarmup(
IServiceScopeFactory scopeFactory,
IMemoryCache cache,
ILogger<CatalogCacheWarmup> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
// Hosted services are singletons, so create a scope for the scoped DbContext.
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<BookstoreDbContext>();
var categories = await db.Categories
.AsNoTracking()
.OrderBy(c => c.Name)
.ToListAsync(cancellationToken);
cache.Set(CacheKeys.Categories, categories);
logger.LogInformation("Catalog cache warmed with {Count} categories", categories.Count);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
builder.Services.AddMemoryCache();
builder.Services.AddHostedService<CatalogCacheWarmup>();
Notice what this service does not do: it doesn’t loop. It does one quick job and returns. That’s what raw IHostedService is good for. Good candidates:
- Warming a cache
- Validating that required configuration or external dependencies exist (fail fast at startup)
- Registering the instance with a service registry, then deregistering in
StopAsync - Flushing buffered telemetry in
StopAsync
About database migrations at startup: running
db.Database.MigrateAsync()in a hosted service is common in demos. In production with more than one instance, several instances can try to migrate at the same time. Prefer running migrations as a separate deployment step (a migration bundle or SQL script).
What if StartAsync throws?
If StartAsync throws, the host fails to start and the app exits. That’s useful for “fail fast” checks: an app that can’t reach a required dependency should crash loudly at startup rather than half-work in production.
BackgroundService: for long-running work
If you try to write a loop inside StartAsync, the host waits forever and Kestrel never starts. For long-running work, you need to start the loop, return from StartAsync immediately, and stop the loop cleanly in StopAsync. Doing that correctly by hand takes a cancellation token source, a stored task, and careful exception handling.
BackgroundService does all of that for you. You override a single method:
public sealed class Heartbeat(ILogger<Heartbeat> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
logger.LogInformation("Still alive at {Time}", DateTimeOffset.UtcNow);
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}
The stoppingToken is cancelled when the host begins shutting down. Your job is to pass it to every await and to exit the loop promptly when it fires.
How BackgroundService works under the hood (.NET 10)
It helps to read the real source. Here is the core of BackgroundService in .NET 10, from dotnet/runtime, slightly trimmed:
public abstract class BackgroundService : IHostedService, IDisposable
{
private Task? _executeTask;
private CancellationTokenSource? _stoppingCts;
public virtual Task? ExecuteTask => _executeTask;
protected abstract Task ExecuteAsync(CancellationToken stoppingToken);
public virtual Task StartAsync(CancellationToken cancellationToken)
{
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
CancellationToken stoppingToken = _stoppingCts.Token;
// Execute all of ExecuteAsync asynchronously
_executeTask = cancellationToken.IsCancellationRequested
? Task.FromCanceled(cancellationToken)
: Task.Run(() => ExecuteAsync(stoppingToken), CancellationToken.None);
// Always return a completed task. Any result from ExecuteAsync is handled by the Host.
return Task.CompletedTask;
}
public virtual async Task StopAsync(CancellationToken cancellationToken)
{
if (_executeTask == null)
return; // Stop called without start
try
{
_stoppingCts!.Cancel(); // signal ExecuteAsync to stop
}
finally
{
// Wait for ExecuteAsync to finish, or for the shutdown timeout
await _executeTask.WaitAsync(cancellationToken)
.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
}
public virtual void Dispose() => _stoppingCts?.Cancel();
}
Four things to take away from this code:
StartAsyncreturns immediately. It startsExecuteAsyncwithTask.Runand hands back a completed task, so the host can move on to the next service.ExecuteTaskrepresents the lifetime of your service. The host watches this task. When it faults, the host reacts (more on that in the exceptions section).StopAsynccancels your token, then waits forExecuteAsyncto finish. It only waits as long as the shutdown token allows. After that, it gives up on your task.- If
ExecuteAsyncreturns, your service is done for good. Nothing restarts it. A loop that accidentally exits leaves you with a dead service in a live app.
What changed in .NET 10
That Task.Run line is new in .NET 10, and it fixes one of the most common hosted-service bugs.
Before .NET 10, StartAsync looked roughly like this:
// .NET 9 and earlier (simplified)
_executeTask = ExecuteAsync(_stoppingCts.Token);
if (_executeTask.IsCompleted)
return _executeTask; // surfaces synchronous failures to the host
return Task.CompletedTask;
ExecuteAsync was called directly on the startup thread. Everything before the first real await ran synchronously, and blocked every service registered after it, including Kestrel. So this innocent-looking code delayed your entire app:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var rules = LoadPricingRulesFromDisk(); // synchronous, takes 20 seconds
// ...nothing else could start until we got here...
while (!stoppingToken.IsCancellationRequested)
{
await ProcessAsync(rules, stoppingToken);
}
}
Developers worked around it by adding await Task.Yield(); as the first line of every ExecuteAsync. Microsoft’s own breaking change notice admits the old behavior “was a common pitfall that didn’t meet user expectations.”
sequenceDiagram
autonumber
participant Host as Generic Host
participant BG as PricingWorker<br/>(BackgroundService)
participant Web as Kestrel
rect rgba(220, 80, 80, 0.12)
Note over Host,Web: .NET 9 and earlier
Host->>BG: StartAsync()
Note over BG: ExecuteAsync runs on the startup thread.<br/>LoadPricingRulesFromDisk() blocks for 20 s
BG-->>Host: returns at the first real await
Host->>Web: StartAsync() (20 s late)
end
rect rgba(80, 180, 120, 0.12)
Note over Host,Web: .NET 10
Host->>BG: StartAsync()
BG-)BG: Task.Run(ExecuteAsync) on the thread pool
BG-->>Host: completed immediately
Host->>Web: StartAsync() (no delay)
Note over BG: LoadPricingRulesFromDisk() runs<br/>in the background
end
In .NET 10, all of ExecuteAsync runs on a thread-pool thread, and no part of it blocks other services from starting. What that means for you:
- You can delete
await Task.Yield()from the top of yourExecuteAsyncmethods on .NET 10. - Synchronous exceptions in
ExecuteAsyncno longer fail startup. Previously, ifExecuteAsyncthrew before its firstawait, the faulted task was returned fromStartAsyncand the host failed to start. Now the exception lands inExecuteTask, and the host handles it throughBackgroundServiceExceptionBehaviorinstead. - If you relied on the old blocking behavior (for example, “load this before Kestrel starts”), you now need to be explicit. Microsoft suggests one of these:
- Put the code in the constructor.
- Override
StartAsyncand do the work before callingbase.StartAsync. The synchronous part of an overriddenStartAsyncstill blocks startup. - Implement
IHostedLifecycleServiceand useStartingAsync. - Use a plain
IHostedServiceinstead ofBackgroundService.
public sealed class PricingWorker(PricingRuleStore store) : BackgroundService
{
// Runs during startup and blocks other services until it finishes, even in .NET 10
public override async Task StartAsync(CancellationToken cancellationToken)
{
await store.LoadAsync(cancellationToken);
await base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Runs on the thread pool in .NET 10
// ...
}
}
One thing didn’t change:
ExecuteAsyncstill runs on a thread-pool thread, so a CPU-heavy loop with noawaitstill ties up a pool thread for as long as it runs. If you run heavy CPU work continuously, consider a separate worker process, or a dedicated thread (TaskCreationOptions.LongRunning) for that specific loop.
IHostedLifecycleService: precise hooks for startup and shutdown
.NET 8 added IHostedLifecycleService, which extends IHostedService with four more methods:
public interface IHostedLifecycleService : IHostedService
{
Task StartingAsync(CancellationToken cancellationToken);
Task StartedAsync(CancellationToken cancellationToken);
Task StoppingAsync(CancellationToken cancellationToken);
Task StoppedAsync(CancellationToken cancellationToken);
}
Each hook runs for all services before the host moves to the next phase. So every service’s StartingAsync runs before any service’s StartAsync, and every StartAsync (including Kestrel’s) finishes before any StartedAsync runs.
Combined with the IHostApplicationLifetime events, the full order, as documented in the Generic Host docs, is:
sequenceDiagram
autonumber
participant Host as Generic Host
participant Svc as All hosted services
participant Life as IHostApplicationLifetime
Note over Host,Life: Startup
Host->>Svc: StartingAsync() on every service
Host->>Svc: StartAsync() on every service (Kestrel starts here)
Host->>Svc: StartedAsync() on every service
Host->>Life: ApplicationStarted fires
Note over Host,Life: App is running
Note over Host,Life: Shutdown signal (Ctrl+C, SIGTERM, StopApplication)
Host->>Life: ApplicationStopping fires
Host->>Svc: StoppingAsync() on every service
Host->>Svc: StopAsync() on every service (reverse order)
Host->>Svc: StoppedAsync() on every service
Host->>Life: ApplicationStopped fires
Where each hook is useful:
| Hook | Runs | Good for |
|---|---|---|
StartingAsync |
Before any service’s StartAsync |
Validation or setup that other services depend on |
StartAsync |
Normal start | Your main startup work |
StartedAsync |
After every service started, including Kestrel | Work that needs the app to be listening, such as registering with a service registry or telling a load balancer you’re ready |
StoppingAsync |
Before any service’s StopAsync |
Stop taking new work: deregister, stop consuming from a broker |
StopAsync |
Normal stop | Finish in-flight work |
StoppedAsync |
After every service stopped | Final flush of logs or metrics |
Here’s a service that registers itself with a discovery service only once the app can actually answer requests, and deregisters before anything else starts shutting down:
public sealed class DiscoveryRegistration(
IDiscoveryClient discovery,
ILogger<DiscoveryRegistration> logger) : IHostedLifecycleService
{
public Task StartingAsync(CancellationToken ct) => Task.CompletedTask;
public Task StartAsync(CancellationToken ct) => Task.CompletedTask;
public async Task StartedAsync(CancellationToken ct)
{
await discovery.RegisterAsync(ct); // Kestrel is listening now
logger.LogInformation("Registered with discovery");
}
public async Task StoppingAsync(CancellationToken ct)
{
await discovery.DeregisterAsync(ct); // stop new traffic first
logger.LogInformation("Deregistered from discovery");
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
public Task StoppedAsync(CancellationToken ct) => Task.CompletedTask;
}
A BackgroundService can implement IHostedLifecycleService too. You just add the interface and the four methods.
Which one should you use?
flowchart TD
Start([I need work to run outside a request]) --> Q1{Does it loop or<br/>keep running?}
Q1 -- "No, one-shot" --> Q2{Must it finish before<br/>Kestrel starts?}
Q2 -- Yes --> IHS["IHostedService<br/>work in StartAsync"]
Q2 -- "No, it needs the app<br/>to be listening" --> ILS["IHostedLifecycleService<br/>work in StartedAsync"]
Q1 -- Yes --> Q3{Must jobs survive a<br/>restart or deployment?}
Q3 -- Yes --> EXT["Durable job system:<br/>Hangfire, Quartz.NET,<br/>a message broker, or an outbox"]
Q3 -- No --> Q4{Runs on a schedule or<br/>reacts to in-app events?}
Q4 -- "Schedule" --> TIMER["BackgroundService +<br/>PeriodicTimer"]
Q4 -- "In-app events" --> QUEUE["BackgroundService +<br/>Channel#lt;T#gt; queue"]
The rest of this post builds the two most common BackgroundService patterns (timed work and queued work), then covers the production concerns that apply to both.
Scoped services inside a background service
Before any of the patterns, you need to understand this rule, because every non-trivial background service hits it:
Hosted services are registered as singletons. They live for the whole lifetime of the app. An EF Core DbContext is registered as scoped: one instance per HTTP request.
If you inject a scoped service into a singleton, that scoped instance gets captured and lives forever. This is called a captive dependency. In Development, ASP.NET Core’s scope validation catches it and throws at startup. In Production, scope validation is off by default, so the app starts, and you get a single DbContext shared across every iteration, tracking more and more entities, until something breaks.
flowchart LR
subgraph Wrong["Wrong: captive dependency"]
direction TB
W1["ExpiredCartCleanup<br/>(singleton, lives forever)"] --> W2["BookstoreDbContext<br/>(captured, never disposed)"]
W2 --> W3["Change tracker grows<br/>Stale data<br/>Not thread-safe"]
end
subgraph Right["Right: scope per unit of work"]
direction TB
R1["ExpiredCartCleanup<br/>(singleton)"] --> R2["IServiceScopeFactory"]
R2 --> R3["New scope for each run"]
R3 --> R4["Fresh DbContext"]
R4 --> R5["Scope disposed:<br/>DbContext disposed"]
end
The fix is to inject IServiceScopeFactory and create a scope for each unit of work, such as each timer tick or each queue message:
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<BookstoreDbContext>();
// use db...
// scope (and db) disposed at the end of the block
Use CreateAsyncScope with await using, because some services (including DbContext) implement IAsyncDisposable.
A cleaner design is to keep the background service thin. It handles scheduling, scopes, logging, and errors, and resolves a scoped service that contains the actual business logic. The business logic then has no idea it’s running in the background, and you can unit-test it like any other service. The patterns below follow this design.
Pattern 1: timed work with PeriodicTimer
The job: every five minutes, delete shopping carts that haven’t been touched for seven days.
Don’t use System.Threading.Timer for this
Older samples, including the original Microsoft docs, use System.Threading.Timer. It has a nasty property: it fires on schedule whether or not the previous callback has finished. If one cleanup run takes longer than the interval, runs start to overlap. Now two runs hit the same rows at the same time, and if runs keep getting slower, they pile up.
PeriodicTimer (added in .NET 6) works differently. You await the next tick, so a new tick can’t start until your code asks for it. Runs never overlap.
gantt
title Timer vs PeriodicTimer with a 5 s interval and one slow 8 s run
dateFormat ss
axisFormat %S s
section System.Threading.Timer
Run 1 (8 s, slow) :crit, t1, 00, 8s
Run 2 overlaps run 1 :crit, t2, 05, 3s
Run 3 :t3, 10, 3s
section PeriodicTimer
Run 1 (8 s, slow) :p1, 00, 8s
Run 2 waits for run 1 :p2, 08, 3s
Run 3 :p3, 11, 3s
If a run takes longer than the interval,
PeriodicTimerdelivers the missed tick once as soon as you callWaitForNextTickAsyncagain. It doesn’t queue up one tick per missed interval, so slow runs never pile up.
The scoped worker
First, the business logic, as an ordinary scoped service:
public interface ICartCleaner
{
Task<int> DeleteExpiredCartsAsync(CancellationToken cancellationToken);
}
public sealed class CartCleaner(
BookstoreDbContext db,
TimeProvider timeProvider,
IOptions<CartCleanupOptions> options) : ICartCleaner
{
public Task<int> DeleteExpiredCartsAsync(CancellationToken cancellationToken)
{
var cutoff = timeProvider.GetUtcNow() - options.Value.MaxCartAge;
// A single DELETE statement. No entities loaded into memory.
return db.ShoppingCarts
.Where(c => c.LastUpdatedUtc < cutoff)
.ExecuteDeleteAsync(cancellationToken);
}
}
ExecuteDeleteAsync runs one SQL DELETE instead of loading carts and deleting them one by one. (It bypasses the change tracker and soft-delete interceptors, so keep that in mind if your carts are soft-deletable.)
The options class:
public sealed class CartCleanupOptions
{
public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(5);
public TimeSpan MaxCartAge { get; set; } = TimeSpan.FromDays(7);
}
The background service
public sealed class ExpiredCartCleanup(
IServiceScopeFactory scopeFactory,
TimeProvider timeProvider,
IOptions<CartCleanupOptions> options,
ILogger<ExpiredCartCleanup> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Cart cleanup started. Interval: {Interval}", options.Value.Interval);
// Create the timer before the first run, so no tick is missed.
using var timer = new PeriodicTimer(options.Value.Interval, timeProvider);
try
{
// do/while: run once immediately, then on every tick.
do
{
await RunOnceAsync(stoppingToken);
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Normal shutdown. WaitForNextTickAsync throws when the token is cancelled.
}
finally
{
logger.LogInformation("Cart cleanup stopped");
}
}
private async Task RunOnceAsync(CancellationToken stoppingToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var cleaner = scope.ServiceProvider.GetRequiredService<ICartCleaner>();
var deleted = await cleaner.DeleteExpiredCartsAsync(stoppingToken);
if (deleted > 0)
logger.LogInformation("Deleted {Count} expired carts", deleted);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
// One failed run must not kill the service. Log it and try again next tick.
logger.LogError(ex, "Cart cleanup run failed");
}
}
}
Registration:
builder.Services.TryAddSingleton(TimeProvider.System);
builder.Services.Configure<CartCleanupOptions>(
builder.Configuration.GetSection("CartCleanup"));
builder.Services.AddScoped<ICartCleaner, CartCleaner>();
builder.Services.AddHostedService<ExpiredCartCleanup>();
{
"CartCleanup": {
"Interval": "00:05:00",
"MaxCartAge": "7.00:00:00"
}
}
The details that make this production-ready:
try/catchinside each run. A database timeout on one run is logged, and the next tick tries again. Without it, one exception ends the service (see the exceptions section below).when (!stoppingToken.IsCancellationRequested). During shutdown, anOperationCanceledExceptionis expected and isn’t logged as an error.- The
finallylog line. If the service ever stops, you see it in the logs. A service that dies silently is the worst kind of bug. TimeProviderinstead ofDateTime.UtcNow. This makes the service testable with a fake clock, as you’ll see in the testing section.PeriodicTimerhas an important limit: only oneWaitForNextTickAsynccall can be waiting at a time. That’s fine here, because the loop is the only caller.
Running on a cron schedule
PeriodicTimer gives you “every N minutes”. For “every day at 03:00 UTC”, calculate the next occurrence and wait until then. The small, MIT-licensed Cronos library parses cron expressions:
public sealed class NightlyReportWorker(
IServiceScopeFactory scopeFactory,
TimeProvider timeProvider,
ILogger<NightlyReportWorker> logger) : BackgroundService
{
private static readonly CronExpression Schedule = CronExpression.Parse("0 3 * * *"); // 03:00 every day
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var now = timeProvider.GetUtcNow();
var next = Schedule.GetNextOccurrence(now, TimeZoneInfo.Utc);
if (next is null) return;
var delay = next.Value - now;
logger.LogInformation("Next nightly report at {Next} (in {Delay})", next, delay);
try
{
await Task.Delay(delay, timeProvider, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var reports = scope.ServiceProvider.GetRequiredService<IReportGenerator>();
await reports.GenerateDailySalesReportAsync(stoppingToken);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(ex, "Nightly report failed");
}
}
}
}
Be honest with yourself about the limits here. If the app is restarting at 03:00, that night’s run is simply skipped. If you scale out to three instances, the report is generated three times. If either of those matters, read the scale-out section, or use a real scheduler.
Pattern 2: queued background work with Channel
The job: when a customer places an order, return the HTTP response immediately and send the confirmation email in the background.
The anti-pattern: fire-and-forget Task.Run
This is the first thing most developers try:
// Don't do this
app.MapPost("/orders", async (CreateOrder cmd, BookstoreDbContext db, IEmailSender email) =>
{
var order = Order.Create(cmd);
db.Orders.Add(order);
await db.SaveChangesAsync();
_ = Task.Run(async () =>
{
await email.SendOrderConfirmationAsync(order.Id); // might run after the request ends
order.ConfirmationSentAt = DateTime.UtcNow;
await db.SaveChangesAsync(); // ObjectDisposedException: the request scope is gone
});
return Results.Created($"/orders/{order.Id}", order.Id);
});
Everything about this is fragile:
dbbelongs to the request scope. When the response is sent, the scope is disposed, and theDbContextwith it. The background code fails withObjectDisposedException, or worse, sometimes works and sometimes doesn’t, depending on timing.- Exceptions inside the
Task.Runare unobserved. Nobody logs them. - The host doesn’t know this work exists. On shutdown, it’s killed halfway through.
- There’s no limit. A traffic spike starts thousands of concurrent tasks.
The pattern: a bounded channel and a consumer service
The correct design separates producing work from doing work:
flowchart LR
subgraph Request["HTTP request (scoped)"]
EP["POST /orders"] --> SAVE["Save order"]
SAVE --> ENQ["TryEnqueue(<br/>SendOrderConfirmation(orderId))"]
end
ENQ --> CH[("Bounded Channel#lt;T#gt;<br/>(singleton, capacity 1000)")]
ENQ -. queue full .-> R503["503 Service Unavailable"]
EP --> R202["202 Accepted<br/>(returned immediately)"]
subgraph Worker["OrderConfirmationProcessor (BackgroundService)"]
direction TB
CH --> P["Parallel.ForEachAsync<br/>max 4 at a time"]
P --> S1["New scope"]
S1 --> H["OrderConfirmationHandler<br/>(scoped: DbContext, IEmailSender)"]
end
Two decisions here are worth explaining:
- The queue holds data, not code. Microsoft’s samples queue
Func<CancellationToken, ValueTask>delegates. That’s flexible, but closures make it very easy to capture a scoped service from the request (the exact bug above). Queueing a small record such asSendOrderConfirmation(int OrderId)makes that mistake impossible. The consumer loads everything it needs in its own scope. - The channel is bounded. An unbounded queue turns a traffic spike into an out-of-memory crash. A bounded queue gives you backpressure: when it’s full, you either wait for space or reject the request.
The job and the queue:
public sealed record SendOrderConfirmation(int OrderId);
public interface IBackgroundQueue<T>
{
/// <summary>Returns false immediately if the queue is full.</summary>
bool TryEnqueue(T item);
/// <summary>Waits for space if the queue is full.</summary>
ValueTask EnqueueAsync(T item, CancellationToken cancellationToken = default);
IAsyncEnumerable<T> ReadAllAsync(CancellationToken cancellationToken);
}
public sealed class ChannelBackgroundQueue<T> : IBackgroundQueue<T>
{
private readonly Channel<T> _channel;
public ChannelBackgroundQueue(int capacity)
{
_channel = Channel.CreateBounded<T>(new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait, // backpressure, never silently drop
SingleReader = false, // several consumers read in parallel
SingleWriter = false // many requests write concurrently
});
}
public bool TryEnqueue(T item) => _channel.Writer.TryWrite(item);
public ValueTask EnqueueAsync(T item, CancellationToken cancellationToken = default) =>
_channel.Writer.WriteAsync(item, cancellationToken);
public IAsyncEnumerable<T> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
With FullMode = Wait, TryWrite returns false when the channel is full, and WriteAsync waits for space. The other modes (DropOldest, DropNewest, DropWrite) silently throw items away, which is almost never what you want for business work.
The scoped handler contains the real logic:
public sealed class OrderConfirmationHandler(
BookstoreDbContext db,
IEmailSender email,
TimeProvider timeProvider)
{
public async Task HandleAsync(SendOrderConfirmation job, CancellationToken cancellationToken)
{
var order = await db.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == job.OrderId, cancellationToken);
if (order is null || order.ConfirmationSentAt is not null)
return; // deleted, or already handled: safe to run twice
await email.SendOrderConfirmationAsync(order, cancellationToken);
order.ConfirmationSentAt = timeProvider.GetUtcNow();
await db.SaveChangesAsync(cancellationToken);
}
}
Note the ConfirmationSentAt check. It makes the handler idempotent: running it twice for the same order doesn’t send two emails. Background work gets retried in many ways, so design every handler like this.
The consumer:
public sealed class OrderConfirmationProcessor(
IBackgroundQueue<SendOrderConfirmation> queue,
IServiceScopeFactory scopeFactory,
ILogger<OrderConfirmationProcessor> logger) : BackgroundService
{
private const int MaxConcurrency = 4;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = MaxConcurrency,
CancellationToken = stoppingToken
};
try
{
await Parallel.ForEachAsync(queue.ReadAllAsync(stoppingToken), options, ProcessAsync);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Normal shutdown
}
finally
{
logger.LogInformation("Order confirmation processor stopped");
}
}
private async ValueTask ProcessAsync(SendOrderConfirmation job, CancellationToken ct)
{
using var _ = logger.BeginScope(new Dictionary<string, object> { ["OrderId"] = job.OrderId });
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var handler = scope.ServiceProvider.GetRequiredService<OrderConfirmationHandler>();
await handler.HandleAsync(job, ct);
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
// Must catch here: an exception in the body stops Parallel.ForEachAsync for every item.
logger.LogError(ex, "Failed to send confirmation for order {OrderId}", job.OrderId);
}
}
}
Parallel.ForEachAsync over the channel gives you a fixed number of concurrent workers without managing tasks yourself. The try/catch in ProcessAsync is essential: if the body throws, Parallel.ForEachAsync stops processing all items and rethrows.
Registration and the endpoint:
builder.Services.AddSingleton<IBackgroundQueue<SendOrderConfirmation>>(
_ => new ChannelBackgroundQueue<SendOrderConfirmation>(capacity: 1_000));
builder.Services.AddScoped<OrderConfirmationHandler>();
builder.Services.AddHostedService<OrderConfirmationProcessor>();
app.MapPost("/orders", async (
CreateOrder cmd,
BookstoreDbContext db,
IBackgroundQueue<SendOrderConfirmation> queue) =>
{
var order = Order.Create(cmd);
db.Orders.Add(order);
await db.SaveChangesAsync();
if (!queue.TryEnqueue(new SendOrderConfirmation(order.Id)))
{
// Order is saved. Only the email is delayed. A sweeper job can pick it up later.
return Results.Accepted($"/orders/{order.Id}", new { order.Id, emailQueued = false });
}
return Results.Accepted($"/orders/{order.Id}", new { order.Id, emailQueued = true });
});
Here a full queue doesn’t fail the order: the order is already saved, and only the email is delayed. For an endpoint whose whole job is to enqueue work, returning 503 Service Unavailable with a Retry-After header is the honest answer when the queue is full.
The limit of in-memory queues
A Channel<T> lives in memory. When the process stops, everything still in the channel is gone:
- A deployment restarts the app → queued emails are lost.
- The app crashes → queued emails are lost.
- Shutdown takes longer than the timeout → in-flight work is abandoned.
That’s acceptable for work that’s cheap to lose or easy to rebuild: cache refreshes, non-critical notifications, or jobs backed by a database flag that a periodic “sweeper” can find again. (In the example above, a timed service could look for orders where ConfirmationSentAt is null and older than five minutes, and enqueue them again. The idempotent handler makes that safe.)
When losing work is not acceptable, write the job to the database in the same transaction as the business change, and have a background service read from there. That’s the transactional outbox pattern. Or use a durable broker such as Azure Service Bus or RabbitMQ.
Exceptions: what happens when ExecuteAsync throws
Since .NET 6, an unhandled exception in ExecuteAsync stops the whole host by default. The host watches each service’s ExecuteTask. When it faults, the host logs the error and then acts on HostOptions.BackgroundServiceExceptionBehavior:
| Value | Behavior |
|---|---|
StopHost (default) |
Logs the exception and stops the application. In a web app, that means the web server stops too. |
Ignore |
Logs the exception and leaves the app running, without the background service. |
An OperationCanceledException thrown because the stopping token was cancelled during shutdown isn’t treated as a failure.
stateDiagram-v2
[*] --> Running: StartAsync → Task.Run(ExecuteAsync)
Running --> Completed: ExecuteAsync returns<br/>(service is finished for good)
Running --> Cancelled: stoppingToken cancelled<br/>during shutdown
Running --> Faulted: unhandled exception
Faulted --> HostStops: BackgroundServiceExceptionBehavior.StopHost<br/>(default)
Faulted --> Zombie: BackgroundServiceExceptionBehavior.Ignore
Zombie: App keeps serving HTTP,<br/>the worker is dead
HostStops: Process exits.<br/>The orchestrator restarts it
Completed --> [*]
Cancelled --> [*]
HostStops --> [*]
You can switch to Ignore:
builder.Services.Configure<HostOptions>(options =>
{
options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore;
});
I’d argue you usually shouldn’t. With Ignore, you get a “zombie”: the web app looks healthy and keeps serving requests, but your order emails stopped being sent hours ago. Nobody notices until customers complain.
StopHost is loud, and loud is good. In Kubernetes, Azure Container Apps, or App Service, a crashed process is restarted automatically, and the crash shows up in your monitoring.
The real fix is to not let exceptions escape in the first place. Catch them per unit of work (per tick, per message), as both patterns above do. Only truly unrecoverable problems should end ExecuteAsync.
Retry with backoff for unstable dependencies
If the service depends on something that might be down for a while (a broker, a third-party API), add a backoff so you don’t hammer it every second:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var delay = TimeSpan.FromSeconds(1);
var maxDelay = TimeSpan.FromMinutes(2);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ConsumeMessagesAsync(stoppingToken); // runs until something fails
delay = TimeSpan.FromSeconds(1); // reset after a healthy run
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(ex, "Consumer failed. Retrying in {Delay}", delay);
await Task.Delay(delay, stoppingToken);
delay = TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, maxDelay.Ticks));
}
}
}
For more advanced retry policies (jitter, circuit breakers), Polly and Microsoft.Extensions.Resilience work well inside background services too.
Graceful shutdown: finishing the work you started
Shutdown is where background services lose data. Here’s what happens when your container receives SIGTERM (for example, during a deployment):
sequenceDiagram
autonumber
participant K8s as Kubernetes / Docker
participant Host as Generic Host
participant Web as Kestrel
participant BG as OrderConfirmationProcessor
K8s->>Host: SIGTERM
Note over K8s: Grace period timer starts<br/>(Kubernetes default: 30 s)
Host->>Host: ApplicationStopping fires
Note over Host: ShutdownTimeout timer starts<br/>(default: 30 s)
Host->>Web: StopAsync(): stop accepting connections,<br/>drain in-flight requests
Host->>BG: StopAsync()
BG->>BG: stoppingToken cancelled
Note over BG: Finishes the current email,<br/>exits the loop
BG-->>Host: ExecuteTask completed
Host->>Host: ApplicationStopped. Process exits
alt Work takes longer than ShutdownTimeout
Host--xBG: Stops waiting. Work abandoned
end
alt Process still running when the grace period ends
K8s--xHost: SIGKILL (cannot be caught)
end
The timeouts you need to line up
HostOptions.ShutdownTimeout is the total time the host waits for all services to stop. The default is 30 seconds since .NET 6 (some older articles say 5 seconds, which was the default in .NET Core 3.x and .NET 5). When it expires, the host stops waiting. Your ExecuteAsync might still be running, but the process exits anyway.
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(45);
});
Your platform has its own timeout, and it wins. When it expires, the process gets SIGKILL, which can’t be caught:
| Platform | Default grace period | Setting |
|---|---|---|
| Kubernetes | 30 seconds | terminationGracePeriodSeconds on the pod spec |
docker stop |
10 seconds | docker stop --time 60, or stop_grace_period in Compose |
| Azure App Service, Container Apps | Platform-specific | Check the docs for your platform |
The rule: platform grace period > ShutdownTimeout > the longest single unit of work.
If you raise ShutdownTimeout to 45 seconds but leave Kubernetes at its default 30, Kubernetes kills the pod at 30 seconds and your extra 15 seconds never happen. Set both:
spec:
terminationGracePeriodSeconds: 60 # must be larger than ShutdownTimeout (45 s)
containers:
- name: bookstore-api
image: bookstore-api:1.0
And notice docker stop’s 10-second default. It’s shorter than .NET’s 30 seconds, so local Docker testing can hide shutdown problems that only show up in production, or the other way around.
Writing shutdown-friendly code
- Pass
stoppingTokento every async call:Task.Delay, EF Core queries,HttpClientcalls. A call that ignores the token keeps running after shutdown starts. - Never use
Thread.Sleepin a background service. It can’t be cancelled. - Keep units of work short. A job that processes 100,000 rows in one go can’t stop cleanly. Process in batches and check the token between batches.
- Decide what “stop” means for in-flight work. For a small, idempotent job, finishing it is fine. For a long job, stop at the next safe checkpoint and let it be picked up again after restart.
Starting faster: concurrent start and stop
By default, the host starts services one at a time and stops them one at a time. With many services that each do some I/O in StartAsync, startup time adds up. Since .NET 8, you can start and stop them concurrently:
builder.Services.Configure<HostOptions>(options =>
{
options.ServicesStartConcurrently = true;
options.ServicesStopConcurrently = true;
options.StartupTimeout = TimeSpan.FromSeconds(60); // no startup timeout by default
});
Only turn concurrent start on if your services don’t depend on each other’s start order. If CatalogCacheWarmup must finish before another service reads the cache, concurrent start breaks that assumption. The lifecycle hooks (StartingAsync, StartedAsync) are the safer way to express real ordering.
StartupTimeout is worth setting either way. Without it, a StartAsync that hangs on a network call hangs your whole app forever. With it, startup fails with a clear error.
Scaling out: every instance runs every service
This one surprises people in production. A hosted service runs inside each instance of your app. Scale your API to three instances, and you now have three cart-cleanup loops and three nightly report jobs.
flowchart TB
LB["Load balancer"] --> I1 & I2 & I3
subgraph I1["Instance 1"]
A1["API"]
C1["ExpiredCartCleanup"]
N1["NightlyReportWorker"]
end
subgraph I2["Instance 2"]
A2["API"]
C2["ExpiredCartCleanup"]
N2["NightlyReportWorker"]
end
subgraph I3["Instance 3"]
A3["API"]
C3["ExpiredCartCleanup"]
N3["NightlyReportWorker"]
end
C1 & C2 & C3 --> DB[("Database")]
N1 & N2 & N3 --> R["3 copies of the same report emailed"]
LOCK{{"Distributed lock:<br/>only one instance<br/>runs each job"}} -.-> N1
Whether that’s a problem depends on the job:
| Job | Running on every instance is… |
|---|---|
| Local cache refresh | Correct. Each instance has its own cache. |
| Queue consumer (Channel, Service Bus) | Fine. Each item goes to only one consumer. |
DELETE of expired carts |
Mostly harmless, if the operation is idempotent. Wasted work, though. |
| Nightly report email | A bug. Customers get three emails. |
| Charging subscriptions | A serious bug. |
Your options:
-
Make the job idempotent, so running it more than once does no harm. Always worth doing.
-
Use a distributed lock, so only one instance runs the job at a time. The MIT-licensed DistributedLock library supports SQL Server, Postgres, Redis, Azure Blob Storage, and more:
private async Task RunOnceAsync(CancellationToken ct) { var @lock = new SqlDistributedLock("nightly-report", connectionString); await using var handle = await @lock.TryAcquireAsync(cancellationToken: ct); if (handle is null) { logger.LogDebug("Another instance holds the lock. Skipping this run"); return; } // Only one instance gets here at a time await GenerateReportAsync(ct); }A lock prevents concurrent runs. To prevent a second instance running the same job a minute later, also record the last successful run (for example, a
JobRunstable with the date), and check it. -
Move the job to a single-instance worker. Put scheduled jobs in a separate Worker Service with exactly one replica, and keep the API stateless.
-
Use a scheduler that handles this for you (Hangfire, Quartz.NET clustering). See the last section.
Hosting considerations
- Azure App Service: turn on Always On, or the app unloads after a period with no HTTP traffic, and your background loops stop with it. See the App Service pricing tiers post for which tiers support it.
- Serverless or scale-to-zero platforms (Azure Functions Consumption, Container Apps scaled to zero): no instance means no background service. Use the platform’s own triggers instead, such as a Functions timer trigger or a Container Apps Job.
Observability: know that your workers are alive
A background service has no HTTP response to tell you it’s broken. You have to make it visible.
Health checks with a heartbeat
The idea: the worker records a heartbeat every time it completes a loop. A health check reports Unhealthy if the heartbeat is too old. Your orchestrator or monitoring then catches a stuck or dead worker.
flowchart LR
W["ExpiredCartCleanup<br/>each run"] -- "Beat()" --> HB["WorkerHeartbeat<br/>(singleton)<br/>last beat: 10:42:05"]
HC["CartCleanupHealthCheck"] -- "reads" --> HB
P["/health/ready<br/>Kubernetes, App Service,<br/>uptime monitor"] --> HC
HC -- "last beat < 3 intervals ago" --> OK["Healthy"]
HC -- "older" --> BAD["Unhealthy"]
public sealed class WorkerHeartbeat<TWorker>(TimeProvider timeProvider)
{
private long _lastBeatTicks = timeProvider.GetUtcNow().UtcTicks; // healthy at startup
public void Beat() =>
Interlocked.Exchange(ref _lastBeatTicks, timeProvider.GetUtcNow().UtcTicks);
public DateTimeOffset LastBeat =>
new(Interlocked.Read(ref _lastBeatTicks), TimeSpan.Zero);
}
public sealed class CartCleanupHealthCheck(
WorkerHeartbeat<ExpiredCartCleanup> heartbeat,
TimeProvider timeProvider,
IOptions<CartCleanupOptions> options) : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default)
{
var age = timeProvider.GetUtcNow() - heartbeat.LastBeat;
var limit = options.Value.Interval * 3;
return Task.FromResult(age <= limit
? HealthCheckResult.Healthy($"Last run {age.TotalSeconds:F0}s ago")
: HealthCheckResult.Unhealthy($"No run for {age.TotalMinutes:F1} minutes"));
}
}
Call heartbeat.Beat() at the end of each loop iteration in ExpiredCartCleanup, whether or not that run succeeded. (Beat on every iteration if you want “the loop is alive”. Beat only on success if you want “the job is working”. Pick deliberately.)
builder.Services.AddSingleton<WorkerHeartbeat<ExpiredCartCleanup>>();
builder.Services.AddHealthChecks()
.AddCheck<CartCleanupHealthCheck>("cart-cleanup", tags: ["ready"]);
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
Tracing and logging
Background work doesn’t get an automatic trace the way an HTTP request does. Start one yourself with an ActivitySource, so each run appears in OpenTelemetry tools (Application Insights, Jaeger, Aspire dashboard) with its database calls nested underneath:
public static class WorkerTelemetry
{
public static readonly ActivitySource Source = new("Bookstore.Workers");
}
// inside RunOnceAsync
using var activity = WorkerTelemetry.Source.StartActivity("CartCleanup.Run");
var deleted = await cleaner.DeleteExpiredCartsAsync(stoppingToken);
activity?.SetTag("carts.deleted", deleted);
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource("Bookstore.Workers"));
And as shown in the queue processor, use logger.BeginScope to attach the order ID or job ID to every log line written during that unit of work.
Testing background services
Because the business logic lives in scoped services (CartCleaner, OrderConfirmationHandler), most of your tests are ordinary unit tests. For the background service itself, you want to verify the scheduling: does it run immediately, does it run again after the interval, does it survive a failed run?
Waiting five real minutes in a test is not an option. That’s why ExpiredCartCleanup takes a TimeProvider. In tests, use FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package, and move time forward instantly:
public sealed class FakeCartCleaner : ICartCleaner
{
private readonly SemaphoreSlim _calls = new(0);
public bool ThrowOnNextCall { get; set; }
public Task<int> DeleteExpiredCartsAsync(CancellationToken cancellationToken)
{
_calls.Release();
if (ThrowOnNextCall)
{
ThrowOnNextCall = false;
throw new TimeoutException("Database timeout");
}
return Task.FromResult(0);
}
public Task<bool> WaitForCallAsync() => _calls.WaitAsync(TimeSpan.FromSeconds(5));
}
public class ExpiredCartCleanupTests
{
private static (ExpiredCartCleanup Sut, FakeCartCleaner Cleaner, FakeTimeProvider Time) Create()
{
var cleaner = new FakeCartCleaner();
var time = new FakeTimeProvider();
var provider = new ServiceCollection()
.AddScoped<ICartCleaner>(_ => cleaner)
.BuildServiceProvider();
var sut = new ExpiredCartCleanup(
provider.GetRequiredService<IServiceScopeFactory>(),
time,
Options.Create(new CartCleanupOptions { Interval = TimeSpan.FromMinutes(5) }),
NullLogger<ExpiredCartCleanup>.Instance);
return (sut, cleaner, time);
}
[Fact]
public async Task Runs_immediately_and_then_on_every_interval()
{
var (sut, cleaner, time) = Create();
await sut.StartAsync(CancellationToken.None);
Assert.True(await cleaner.WaitForCallAsync()); // first run, immediately
time.Advance(TimeSpan.FromMinutes(5));
Assert.True(await cleaner.WaitForCallAsync()); // second run, after one interval
await sut.StopAsync(CancellationToken.None);
}
[Fact]
public async Task Keeps_running_after_a_failed_run()
{
var (sut, cleaner, time) = Create();
cleaner.ThrowOnNextCall = true;
await sut.StartAsync(CancellationToken.None);
Assert.True(await cleaner.WaitForCallAsync()); // this run throws
time.Advance(TimeSpan.FromMinutes(5));
Assert.True(await cleaner.WaitForCallAsync()); // the service survived
Assert.False(sut.ExecuteTask!.IsFaulted);
await sut.StopAsync(CancellationToken.None);
}
}
Creating the PeriodicTimer before the first run (as we did in ExpiredCartCleanup) matters for these tests: once the first call is observed, the timer is guaranteed to exist, so Advance always fires it.
Turning hosted services off in integration tests
In WebApplicationFactory integration tests, you usually don’t want the cleanup job deleting your test data in the middle of a test. Remove that specific service:
public sealed class ApiFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
var descriptor = services.Single(d =>
d.ServiceType == typeof(IHostedService) &&
d.ImplementationType == typeof(ExpiredCartCleanup));
services.Remove(descriptor);
});
}
}
Don’t call services.RemoveAll<IHostedService>(). That also removes services the framework itself relies on.
Registration gotchas
A few surprises in how AddHostedService works:
Registering the same type twice does nothing. AddHostedService<T>() uses TryAddEnumerable, so a second registration of the same type is silently ignored. To run two instances of a worker (for example, two queue consumers with different settings), use separate types, or one service that starts several loops internally, as OrderConfirmationProcessor does.
You can’t inject a hosted service by its type. AddHostedService<T>() registers it only as IHostedService. If an endpoint needs to talk to the worker (say, to read its status), register it as a singleton first and point the hosted service registration at the same instance:
builder.Services.AddSingleton<ExpiredCartCleanup>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExpiredCartCleanup>());
Often it’s cleaner to put the shared state in its own singleton (like WorkerHeartbeat<T>) and inject that into both.
Registration order is start order. Services start in the order you register them (unless you enable concurrent start) and stop in reverse order.
Running outside a web app: the Worker Service template
Everything in this post works the same in a Worker Service: a console app with the Generic Host but no web server. This is the natural home for background work that shouldn’t share CPU and memory with your API, or that must run as a single instance:
dotnet new worker -n Bookstore.Worker
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddDbContext<BookstoreDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Bookstore")));
builder.Services.AddScoped<ICartCleaner, CartCleaner>();
builder.Services.AddHostedService<ExpiredCartCleanup>();
var host = builder.Build();
host.Run();
From there you can run it:
- In a container, the most common choice today
- As a Windows Service, with
Microsoft.Extensions.Hosting.WindowsServicesandbuilder.Services.AddWindowsService(o => o.ServiceName = "Bookstore Worker"); - As a Linux systemd service, with
Microsoft.Extensions.Hosting.Systemdandbuilder.Services.AddSystemd();
The Worker template also supports Native AOT (dotnet new worker --aot) for fast startup and small memory use, provided your dependencies are AOT-compatible. (EF Core’s AOT support still has limits, so check before committing to it.)
When to stop using hosted services
Hosted services are a great building block. They are not a job system. Once you need any of the following, you’re rebuilding features that existing tools already have:
- Jobs that survive restarts and deployments
- Automatic retries with a retry history
- A dashboard showing what ran, what failed, and what’s queued
- Cron schedules that run exactly once across many instances
- Delayed jobs (“send this reminder in 3 days”)
| Need | Hosted service | Hangfire | Quartz.NET | Message broker / outbox |
|---|---|---|---|---|
| Simple loop or timer | ✅ Ideal | Overkill | Overkill | Overkill |
| One-shot startup work | ✅ Ideal | ❌ | ❌ | ❌ |
| In-process queue, OK to lose on restart | ✅ Channel<T> |
Overkill | Overkill | Overkill |
| Survives restarts | ❌ (hand-rolled) | ✅ (database-backed) | ✅ (with a persistent job store) | ✅ |
| Retries and failure history | ❌ (hand-rolled) | ✅ | Partial | ✅ (dead-letter queues) |
| Dashboard | ❌ | ✅ | Third-party | Broker-specific |
| Cron, once across many instances | ❌ (needs a lock) | ✅ | ✅ (clustering) | ❌ (not its job) |
| Cross-service events | ❌ | ❌ | ❌ | ✅ |
A few notes on these tools:
- Hangfire stores jobs in your database and includes a dashboard. The core is free under LGPL, and Hangfire Pro is a paid add-on.
- Quartz.NET (Apache 2.0) is a powerful scheduler with cron triggers, persistent job stores, and clustering, and it integrates with the Generic Host.
- Message brokers (Azure Service Bus, RabbitMQ), usually with Wolverine or MassTransit on top, are the right tool when the work crosses service boundaries. Check MassTransit’s current licence before adopting it: v9 is commercial.
- Platform schedulers (Azure Functions timer triggers, Container Apps Jobs, Kubernetes CronJobs) run work without keeping an instance alive.
Many apps end up using both: hosted services for in-process plumbing (cache refresh, consuming from a broker, outbox dispatch) and a real job system for business jobs.
Checklist
Before you ship a background service, check these:
- Long-running work uses
BackgroundService.StartAsyncstays short. - Scoped services (like
DbContext) are resolved from a new scope per unit of work. - Each tick or message has its own try/catch, so one failure doesn’t kill the service.
-
stoppingTokenis passed to every async call. NoThread.Sleep. - Entry and exit (in
finally) are logged, so a dead service is visible. - Timed work uses
PeriodicTimer, notSystem.Threading.Timer. - Queues are bounded and hold data, not closures.
- Handlers are idempotent.
-
ShutdownTimeout< platform grace period, and both are set explicitly. - You’ve decided what happens with more than one instance.
- A health check reports whether the worker is alive.
- You’ve accepted that in-memory work is lost on restart, or you use a durable store.
- On .NET 10: removed
await Task.Yield()workarounds, and moved any “must run before Kestrel” code intoStartAsyncbeforebase.StartAsync.
Conclusion
Hosted services give you a lot for very little code: background work that starts and stops with your app, shares its DI container and configuration, and needs no extra infrastructure.
The rules that matter:
- Use
IHostedServicefor short one-shot work,BackgroundServicefor loops, andIHostedLifecycleServicewhen you need precise timing. - .NET 10 runs all of
ExecuteAsyncin the background, which fixes the old startup-blocking trap. Code that needs to run during startup now goes inStartAsyncbeforebase.StartAsync. - Create a scope per unit of work, catch exceptions per unit of work, and respect the stopping token everywhere.
- Line up your shutdown timeouts with your platform.
- Remember that every instance runs every service, and that in-memory work dies with the process.
When those last two start to hurt, that’s your signal to bring in a durable queue or a real scheduler.
Further reading
- Background tasks with hosted services in ASP.NET Core (Microsoft Learn)
- Breaking change: BackgroundService runs all of ExecuteAsync as a Task (.NET 10)
- .NET Generic Host: lifecycle and shutdown
- Create a queue service (Microsoft Learn,
Channel<T>-based) - Use scoped services within a BackgroundService
HostOptionsAPI reference- BackgroundService source code (dotnet/runtime)
- IHostedService vs BackgroundService in .NET (codewithmukesh)
- Related post on this blog: Azure App Service pricing tiers (Always On)
