Background services are commonly used in .NET applications to process queued messages, send notifications, synchronize data, generate reports, and perform scheduled maintenance tasks.
Unlike an ordinary API request, a background service may run continuously for hours or days. A single unhandled exception can therefore have a much wider impact than a failed request.
For example, imagine a worker that checks a database every few seconds and sends pending email notifications. If one email fails because an external provider is temporarily unavailable, the worker should not necessarily stop processing every other notification.
The goal is to handle failures at the right level. A failure in one job should not automatically terminate the entire background service, while serious infrastructure or application failures should still be visible and handled deliberately.
This article explains how to structure BackgroundService implementations, isolate job failures, handle cancellation, implement retries, and monitor background processing in production.
1. Understand Why BackgroundService Can Stop Unexpectedly
In ASP.NET Core, BackgroundService provides a base class for implementing long-running background operations.
A typical implementation looks like this:
public class NotificationWorker : BackgroundService
{
private readonly ILogger<NotificationWorker> _logger;
public NotificationWorker(
ILogger<NotificationWorker> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation(
"Checking pending notifications");
await Task.Delay(
TimeSpan.FromSeconds(10),
stoppingToken);
}
}
}The ExecuteAsync method represents the lifetime of the background service.
If an exception escapes from this method, the hosting infrastructure observes the failure.
Starting with .NET 6, the default BackgroundService exception behavior is StopHost. This means an unhandled exception in a background service causes the host to stop.
This behavior is intentional. It prevents an application from appearing healthy while an important background service has silently stopped working.
Consider the following example:
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await ProcessNotificationsAsync(stoppingToken);
await Task.Delay(
TimeSpan.FromSeconds(10),
stoppingToken);
}
}If ProcessNotificationsAsync throws an exception, the loop exits. The exception reaches the hosting infrastructure, and the host can stop.
The important distinction is that the host does not automatically restart the failed BackgroundService when an exception occurs.
If you want the worker to continue after an individual job fails, you must handle that failure inside the worker.
2. Handle Exceptions at the Individual Job Level
The first improvement is to isolate exceptions around the operation that can fail.
For example, a notification worker should not stop processing all notifications because one notification fails.
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessNotificationsAsync(stoppingToken);
}
catch (OperationCanceledException)
when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"An error occurred while processing notifications");
}
await Task.Delay(
TimeSpan.FromSeconds(10),
stoppingToken);
}
}This implementation catches unexpected exceptions from the processing operation and allows the loop to continue.
However, it is important to understand what this code actually protects.
If ProcessNotificationsAsync processes 100 notifications internally and fails on the 50th notification, the remaining notifications may not be processed during that iteration.
Catching the exception keeps the worker alive, but it does not automatically recover the failed operation or continue from the exact point of failure.
For better isolation, handle exceptions around individual jobs.
3. Process Each Job Independently
A more reliable design separates retrieving jobs from processing them.
Imagine a system that retrieves pending email notifications from a database.
You can process each notification independently:
public async Task ProcessNotificationsAsync(
CancellationToken cancellationToken)
{
var notifications = await GetPendingNotificationsAsync(
cancellationToken);
foreach (var notification in notifications)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await SendNotificationAsync(
notification,
cancellationToken);
await MarkAsCompletedAsync(
notification.Id,
cancellationToken);
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to process notification {NotificationId}",
notification.Id);
await MarkAsFailedAsync(
notification.Id,
ex.Message,
cancellationToken);
}
}
}Now, a failure in one notification does not prevent the worker from processing the next notification.
This approach provides several advantages:
Individual failures are isolated.
Successful jobs can be marked as completed.
Failed jobs can be recorded for later processing.
Logs can identify the exact job that failed.
The background service can continue processing other jobs.
For production systems, recording failure information is important. Simply catching an exception and continuing can hide data-processing problems.
A job should have a clearly defined state, such as Pending, Processing, Completed, or Failed.
4. Implement Controlled Retries for Transient Failures
Not every failure requires manual intervention.
Some failures are temporary. An external API might return a timeout, a database connection might temporarily become unavailable, or a network request might fail because of a short-lived connectivity problem.
Retrying these operations can help.
However, retries should be limited and controlled.
Consider this example:
public async Task SendWithRetryAsync(
Notification notification,
CancellationToken cancellationToken)
{
const int maxAttempts = 3;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
await _emailClient.SendAsync(
notification,
cancellationToken);
return;
}
catch (HttpRequestException ex)
when (attempt < maxAttempts)
{
_logger.LogWarning(
ex,
"Notification attempt {Attempt} failed",
attempt);
var delay = TimeSpan.FromSeconds(
Math.Pow(2, attempt));
await Task.Delay(delay, cancellationToken);
}
}
throw new InvalidOperationException(
"Notification delivery failed after all retry attempts.");
}This example uses exponential backoff.
The delay increases between attempts:
Attempt | Delay |
|---|---|
1 | 2 seconds |
2 | 4 seconds |
3 | No additional retry |
The example is intentionally simple. Production retry policies should also consider:
Whether the failure is transient.
HTTP status codes and Retry-After headers.
Maximum retry duration.
Randomized delay, also called jitter.
Whether the operation is safe to repeat.
Cancellation during retry delays.
Avoid retrying every exception. For example, an invalid email address or an authentication failure will not usually be fixed by repeating the same request.
For more advanced retry and timeout handling, .NET applications can use the Microsoft.Extensions.Http.Resilience package with HttpClient.
5. Configure BackgroundService Exception Behavior Carefully
.NET provides BackgroundServiceExceptionBehavior through HostOptions.
It supports two values:
StopHost: Stops the host when an unhandled BackgroundService exception occurs.
Ignore: Logs the exception but does not stop the host or restart the failed service.
You can configure it as follows:
builder.Services.Configure<HostOptions>(options =>
{
options.BackgroundServiceExceptionBehavior =
BackgroundServiceExceptionBehavior.Ignore;
});This changes the host's response to an unhandled exception.
However, there is an important limitation.
Setting Ignore does not restart the BackgroundService. If ExecuteAsync has already completed because of an exception, the worker does not automatically start again.
Join the conversation! Your thoughts help the community grow.