Add a Resilient Periodic Background Worker to a .NET Generic Host
Run scheduled background work in a .NET generic host with graceful shutdown, cancellation-token delays, and basic failure handling.
18 Jul 2025, 16:44 UTC

Problem and takeaway
You need code to run on a schedule inside a .NET application without blocking startup or preventing the process from exiting cleanly. A bare Timer or while (true) loop in Main can keep the host alive past shutdown or swallow errors silently.
Use BackgroundService from Microsoft.Extensions.Hosting. It integrates with the generic host, receives a cancellation token for shutdown, and lets you implement retry logic inside a controlled loop.
Desired outcome
A hosted service that wakes at a fixed interval, performs work, logs start/stop and errors, exits promptly when the host stops, and either continues after transient failures or stops the host for fatal ones.
Prerequisites
- .NET 5 or later; examples assume .NET 6+ with
Host.CreateApplicationBuilderorHost.CreateDefaultBuilder. - A console or worker service project, not a bare
Mainloop. - The
Microsoft.Extensions.Hostingpackage, included by default in the .NET SDK worker and web templates.
Create the worker class
Derive from BackgroundService and override ExecuteAsync. Pass the stoppingToken into every asynchronous wait so shutdown is not delayed until the next interval.
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Example.Worker;
public class PeriodicWorker : BackgroundService
{
private readonly ILogger _logger;
private readonly TimeSpan _interval = TimeSpan.FromSeconds(30);
public PeriodicWorker(ILogger logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Periodic worker starting");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await DoWorkAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Expected during shutdown; exit the loop.
break;
}
catch (Exception ex)
{
// Log transient failures and continue. Fatal exceptions should propagate.
_logger.LogError(ex, "Worker task failed; retrying on next interval");
}
// Delay respects the cancellation token. Without it, shutdown can hang
// until the full interval elapses.
await Task.Delay(_interval, stoppingToken);
}
_logger.LogInformation("Periodic worker stopping");
}
private Task DoWorkAsync(CancellationToken token)
{
// Replace with actual work. Throwing here tests failure handling.
return Task.CompletedTask;
}
}
Do not call Task.Delay without the token, and do not block with .Result or .Wait() inside ExecuteAsync. Blocking starves the thread pool and can prevent graceful shutdown.
Register the hosted service
For a generic host console application, register the worker in the service collection:
using Microsoft.Extensions.Hosting;
using Example.Worker;
IHost host = Host.CreateApplicationBuilder(args).Build();
host.Services.AddHostedService();
await host.RunAsync();
If you are using WebApplicationBuilder in ASP.NET Core, call builder.Services.AddHostedService() before builder.Build(). The registration API differs between the generic host and minimal web hosting, so match the call to your hosting model.
Avoid registering the same hosted service twice. Duplicate registration creates overlapping workers that can contend for the same resources.
Add failure handling and backoff
The example above logs exceptions and continues. For transient failures such as network timeouts, wrap the work in a retry policy with backoff:
int attempt = 0;
while (!stoppingToken.IsCancellationRequested)
{
try
{
await DoWorkAsync(stoppingToken);
attempt = 0;
}
catch (Exception ex) when (attempt < 3)
{
attempt++;
var backoff = TimeSpan.FromSeconds(Math.Pow(2, attempt));
_logger.LogWarning(ex, "Attempt {Attempt} failed; retrying in {Backoff}", attempt, backoff);
await Task.Delay(backoff, stoppingToken);
continue;
}
catch (Exception ex)
{
_logger.LogError(ex, "Worker failed after retries");
throw; // Let the host stop for unrecoverable failures
}
await Task.Delay(_interval, stoppingToken);
}
An unhandled exception in ExecuteAsync stops the host by default. Catch and log only when continued operation is the intended recovery path. Let fatal exceptions propagate if the process should not run without the worker.
Expected checks
- Start the application and confirm the worker start message appears in the logs.
- Stop the host (Ctrl+C or
SIGTERM) and confirm the stopping message appears within the default shutdown period. - Verify
stoppingToken.IsCancellationRequestedbecomes true during shutdown and the loop exits before the next scheduled delay completes. - Inject a temporary exception into
DoWorkAsyncand confirm the host behaves as intended: either it logs and continues, or it exits.
Limitations
BackgroundServiceis suited for long-running periodic work, not short fire-and-forget tasks.- If
ExecuteAsyncthrows unhandled, the host stops; there is no automatic restart unless you wrap the registration or use a supervisor. - Work that runs longer than the interval overlaps with the next tick unless you add concurrency guards.
Practical verification
Run the project from a terminal in the project folder:
dotnet run
No special permissions are required. Watch the console output for the start and stop messages. To verify shutdown timing, stop the process and measure whether the stop message appears before the configured interval elapses. If it does not, check that stoppingToken is passed to Task.Delay.
Rollback
This change modifies source code only. To revert, remove the PeriodicWorker class and the AddHostedService registration, then rebuild. No database or infrastructure state is changed.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.