Running Reliable Background Tasks with IHostedService in .NET Core
Replace ad-hoc threads with IHostedService and BackgroundService to run reliable background tasks that respect shutdown, handle scoped dependencies, and avoid crashing your .NET Core application.
07 Apr 2026, 22:48 UTC

The Problem: Ad-Hoc Background Work Breaks at Scale
Many .NET Core applications start background work by firing Task.Run, a Timer, or a raw Thread in Program.Main. That works for a quick prototype, but it creates three predictable failures in production:
- The work ignores application shutdown, so the process hangs or gets killed mid‑operation.
- Unhandled exceptions in the background thread crash the entire host.
- Scoped services (like
DbContext) cannot be resolved because there is no request scope.
The fix is to use the host’s built‑in IHostedService contract. BackgroundService (introduced in .NET Core 2.1) wraps that contract in a single ExecuteAsync method, giving you lifecycle integration, cancellation tokens, and a clear place to manage scopes.
Registration and Lifecycle
Register a hosted service in Program.cs (or Startup.ConfigureServices) with AddHostedService<T>(). The host starts each service in registration order by calling StartAsync, which for BackgroundService launches ExecuteAsync on a thread‑pool thread. On shutdown the host signals the CancellationToken passed to ExecuteAsync, waits for the task to complete, then calls StopAsync in reverse registration order.
// Program.cs (.NET 6+ minimal hosting)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHostedService<OrderCleanupWorker>();
var app = builder.Build();
app.Run();
Each registration creates a singleton instance. If you need multiple instances of the same worker type, register a factory: AddHostedService(sp => new OrderCleanupWorker(sp.GetRequiredService<ILogger<OrderCleanupWorker>>())).
Scoped Dependencies Inside a Worker
A hosted service constructor runs once at startup, so it can only accept singleton or transient dependencies. To use scoped services (Entity Framework DbContext, per‑request caches, etc.) inject IServiceScopeFactory and create a scope inside the loop:
public sealed class OrderCleanupWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OrderCleanupWorker> _logger;
private readonly TimeSpan _interval = TimeSpan.FromMinutes(5);
public OrderCleanupWorker(IServiceScopeFactory scopeFactory,
ILogger<OrderCleanupWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(\"OrderCleanupWorker starting.\");
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cutoff = DateTime.UtcNow.AddDays(-30);
var stale = await db.Orders
.Where(o => o.CreatedAt < cutoff && o.Status == OrderStatus.Pending)
.ToListAsync(stoppingToken);
foreach (var order in stale)
{
order.Status = OrderStatus.Cancelled;
}
await db.SaveChangesAsync(stoppingToken);
_logger.LogInformation(\"Cancelled {Count} stale orders.\", stale.Count);
try
{
await Task.Delay(_interval, stoppingToken);
}
catch (OperationCanceledException)
{
break; // exit loop cleanly
}
}
_logger.LogInformation(\"OrderCleanupWorker stopped.\");
}
}
Key points in the example:
- The
using var scopeensures the scopedDbContextis disposed each iteration, releasing connections and preventing leaks. stoppingTokenis passed to every async call (ToListAsync,SaveChangesAsync,Task.Delay) so the loop exits promptly when the host shuts down.- Catching
OperationCanceledExceptionfromTask.Delayavoids logging an error for normal shutdown.
Cancellation and Graceful Shutdown
The CancellationToken supplied to ExecuteAsync is linked to IHostApplicationLifetime.ApplicationStopping. When the host receives a shutdown signal (Ctrl+C, Kubernetes SIGTERM, IIS stop), it triggers that token and then waits for ExecuteAsync to return. The default timeout is 5 seconds in .NET 6 and 30 seconds in .NET 8; configure it via HostOptions.ShutdownTimeout if your work needs more time.
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(60));
If your code ignores the token, the host will forcibly terminate the process after the timeout, potentially leaving data in an inconsistent state. Always pass the token to async APIs and check IsCancellationRequested in long‑running synchronous loops.
Trade‑offs and Limitations
| Aspect | Implication |
|---|---|
| Shared process | Memory leaks or unhandled exceptions in a worker crash the web server. Wrap the loop body in try/catch and log, but do not swallow OperationCanceledException. |
| Thread‑pool contention | Blocking or CPU‑heavy synchronous work inside ExecuteAsync starves the pool, delaying HTTP requests and other workers. Offload heavy work to Task.Run or a dedicated ThreadPool with ThreadPool.QueueUserWorkItem. |
| Shutdown timeout defaults | Vary by framework version (.NET 6: 5 s, .NET 8: 30 s). Verify the value for your target LTS and set HostOptions.ShutdownTimeout explicitly. |
| No built‑in scheduling | BackgroundService runs a continuous loop. For cron‑style schedules use a library like Quartz.NET or Coravel that also implements IHostedService. |
Verification Checklist
- Run the application locally and observe structured logs: startup message, periodic \"Cancelled N stale orders\", and a clean shutdown message when you press Ctrl+C.
- Deploy to a staging environment, trigger a rolling restart, and confirm the worker finishes its current iteration before the pod/container terminates (check logs for the shutdown message).
- Inject a deliberate
throw new Exception(\"test\")inside the loop, restart, and verify the process stays alive (the exception is logged but not re‑thrown). Remove the test throw afterward. - Monitor thread‑pool usage (
dotnet-countersorSystem.Diagnostics.Tracing) to ensure the worker isn’t starving request threads.
Closing Action
Replace any Task.Run or Timer background work with a BackgroundService derived class. Register it with AddHostedService, inject IServiceScopeFactory for scoped dependencies, and pass the cancellation token to every async call. Test shutdown behavior in a staging environment before promoting to production. This pattern gives you reliable, observable background processing that plays nicely with the host lifecycle across .NET 6, 7, and 8.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.