Adding a Resilient In-Process Background Worker to ASP.NET Core
Learn how to implement a cancellation-aware BackgroundService in ASP.NET Core, resolve scoped dependencies safely, and verify graceful shutdown.
12 Oct 2026, 17:42 UTC

Desired Outcome
You need recurring work—cache warmup, queue polling, or scheduled cleanup—to run inside the same process as your ASP.NET Core application. The worker should start quickly, process one unit of work per interval, survive transient errors, and stop cleanly when the host receives a shutdown signal. A naive timer or fire-and-forget task often blocks startup, leaks scoped services, or hangs on Ctrl+C.
Prerequisites
- A .NET SDK and runtime installed; the project targets .NET 6 or later if you want to use
PeriodicTimer, a timer that awaits the next tick without blocking a thread. For earlier .NET Core versions, substituteTask.Delaywith the cancellation token. - An ASP.NET Core or Worker host with dependency injection and logging already configured.
- A cancellation-aware async workflow:
ExecuteAsyncmust acceptstoppingTokenand observe it. - A decision that the work is lightweight and non-durable. If the job must survive restarts or handle high volume, use an external queue or outbox—a pattern that stores messages durably alongside your business data—with a separate worker process instead.
Procedure
1. Implement the worker
Create a class that inherits from BackgroundService. Override ExecuteAsync(CancellationToken stoppingToken) as async Task, never async void. Do not perform long work in StartAsync; only start the loop and return.
public class CacheWarmupWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<CacheWarmupWorker> _logger;
private readonly TimeSpan _interval = TimeSpan.FromMinutes(5);
public CacheWarmupWorker(
IServiceScopeFactory scopeFactory,
ILogger<CacheWarmupWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("CacheWarmupWorker starting.");
using var timer = new PeriodicTimer(_interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
using var scope = _scopeFactory.CreateScope();
var processor = scope.ServiceProvider
.GetRequiredService<ICacheProcessor>();
await processor.WarmAsync(stoppingToken);
_logger.LogInformation("Cache warmup iteration completed.");
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Transient error during cache warmup.");
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
_logger.LogInformation("CacheWarmupWorker stopped.");
}
}
The five-minute interval is a placeholder; choose a value that matches your workload. On .NET Core versions before 6, replace the PeriodicTimer block with a loop that calls await Task.Delay(_interval, stoppingToken) at the end of each iteration.
2. Register the hosted service
In Program.cs, register the worker:
builder.Services.AddHostedService<CacheWarmupWorker>();
Hosted services are registered as singletons. Do not inject scoped services—such as DbContext—into the constructor; they may be disposed before the worker uses them.
3. Resolve scoped dependencies per iteration
Inject IServiceScopeFactory (or IServiceProvider) and create a new scope for each unit of work. This ensures scoped services are created and disposed within the same iteration.
4. Keep the loop alive for transient failures
Catch expected exceptions inside the loop, log them with a scope or correlation identifier, and apply bounded backoff. Let OperationCanceledException propagate or exit the loop when stoppingToken is signaled. Avoid fire-and-forget Task.Run; track or await tasks so shutdown is observable. Never call Process.Exit or Environment.Exit to force shutdown; doing so bypasses graceful stop and may lose state.
5. Review host shutdown behavior
In .NET 8 and later, inspect HostOptions.BackgroundServiceExceptionBehavior and HostOptions.ShutdownTimeout. Unhandled background exceptions and graceful-stop timing are version-sensitive; do not assume older defaults carry forward. Verify the exact enum values and defaults against the documentation for your target framework.
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});
Expected Checks
| Check | What to confirm |
|---|---|
| Startup | Logs show the worker starting; StartAsync returns promptly without blocking. |
| Iteration | Each cycle completes; no overlapping iterations appear in logs. |
| Graceful stop | On Ctrl+C or SIGTERM—the signal sent by most container orchestrators and process managers—the token is observed, the loop exits, and StopAsync finishes within the configured timeout. |
| Error handling | No unobserved task exceptions; no ObjectDisposedException from scoped services. |
Recovery Options and Limitations
Shutdown hangs: confirm every timer or delay observes stoppingToken. Compare the observed shutdown duration with HostOptions.ShutdownTimeout.
Scoped service errors: if you see disposal exceptions, move scope creation inside the loop rather than the constructor.
Host stops on background error: in .NET 8+, review BackgroundServiceExceptionBehavior. In earlier versions, wrap the loop body in try-catch so the service stays alive.
Rollback: remove the AddHostedService<T>() registration and delete the worker class. No database or infrastructure changes are required.
Limitation: in-process workers do not survive process crashes or restarts. For durable or high-volume work, use an external queue or outbox with a separate worker process or container.
Practical Verification
Run the host locally from your project directory with dotnet run. Send a graceful stop (Ctrl+C or SIGTERM) during an iteration. Verify the log shows cancellation handling and the process exits within the shutdown timeout. For automated checks, write an integration test that starts the host, waits for one iteration, stops the host, and asserts that StartAsync and StopAsync were called.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.