ASP.NET Core applications often run background tasks alongside HTTP requests. These tasks may process queued messages, send emails, generate reports, synchronize data, or perform scheduled maintenance.
Problems can occur when an application shuts down while a background task is still running. A container may restart during deployment, an administrator may stop a Windows service, or a hosting platform may terminate an application after a timeout.
If the application does not handle shutdown correctly, it may abandon unfinished work, process the same message more than once, or leave a business operation in an inconsistent state.
ASP.NET Core provides hosted services and cancellation tokens to support graceful shutdown. However, a reliable implementation requires more than catching an exception. Developers must decide when to stop accepting new work, how to handle active operations, and how to recover tasks that cannot finish before the process exits.
This article explains how to implement graceful shutdown in ASP.NET Core, handle cancellation correctly, protect background jobs, and test shutdown behavior before deploying an application to production.
Understanding Graceful Shutdown in ASP.NET Core
Graceful shutdown is the process of stopping an application in an orderly manner rather than terminating it immediately.
When the ASP.NET Core host receives a shutdown signal, it begins stopping the application and its hosted services. Background services receive a cancellation signal and are expected to stop their ongoing work within the available shutdown period.
A typical shutdown sequence includes the following steps:
- The application receives a shutdown signal.
- Background services stop accepting new work.
- Active operations finish or reach a recoverable state.
- Resources such as database connections and message consumers are released.
- The application exits.
The hosting environment also matters. A container platform, Windows service manager, or cloud hosting service may impose its own termination deadline. If the application exceeds that deadline, the process may be terminated before cleanup finishes.
Why Background Services Need Cancellation Support
Consider a worker that processes an order from a queue. If the application shuts down while the worker is processing the order, the operation may be interrupted.
Without a recovery strategy, the order could remain incomplete. If the queue delivers the message again, the worker might also repeat an operation that already succeeded.
Cancellation tokens allow background services to respond to shutdown requests. They do not forcibly stop arbitrary code, and they do not guarantee that a remote operation has been cancelled.
Developers must therefore combine cancellation with appropriate transaction boundaries, idempotency, and recovery mechanisms.
Implementing a BackgroundService With Cancellation Support
ASP.NET Core provides the BackgroundService class for implementing long-running background operations.
The following example shows a maintenance worker that performs a task periodically and stops when the host requests shutdown.
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public sealed class MaintenanceService : BackgroundService
{
private readonly ILogger<MaintenanceService> _logger;
public MaintenanceService(
ILogger<MaintenanceService> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
_logger.LogInformation(
"Maintenance service started.");
try
{
while (!stoppingToken.IsCancellationRequested)
{
await RunMaintenanceAsync(stoppingToken);
await Task.Delay(
TimeSpan.FromMinutes(1),
stoppingToken);
}
}
catch (OperationCanceledException)
when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation(
"Maintenance service is stopping.");
}
}
private async Task RunMaintenanceAsync(
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// Perform maintenance using cancellation-aware operations.
await Task.CompletedTask;
}
}Register the service in Program.cs:
builder.Services.AddHostedService<MaintenanceService>();The worker passes the stopping token to the maintenance operation and the delay. When the host requests shutdown, the delay can be cancelled and the loop can exit.
The maintenance method is a placeholder. In a real application, pass the cancellation token to database queries, HTTP requests, or other operations that support cancellation.
Handling Cancellation Exceptions
An OperationCanceledException can indicate that an operation was cancelled intentionally. It can also occur when an operation has its own timeout or another cancellation source.
For that reason, the example handles the exception only when the host's stopping token has been cancelled.
Avoid catching every exception and silently continuing. Unexpected failures should be logged and handled according to the worker's recovery policy.
For example, a temporary database failure may justify a bounded retry. A programming error may require the worker to stop or the host to restart the service.
Managing Dependency Injection Scopes in Background Workers
Background services are generally long-lived, so they should not retain request-scoped dependencies indefinitely.
Consider a worker that uses Entity Framework Core to process database records. A DbContext is normally registered as a scoped service. Injecting a scoped dependency into a long-lived worker and retaining it can cause lifetime problems and unnecessary resource retention.
A common solution is to create a dependency injection scope for each unit of work.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public sealed class OrderProcessingService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OrderProcessingService> _logger;
public OrderProcessingService(
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessingService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await using var scope =
_scopeFactory.CreateAsyncScope();
var processor = scope.ServiceProvider
.GetRequiredService<OrderProcessor>();
await processor.ProcessNextAsync(stoppingToken);
}
catch (OperationCanceledException)
when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
_logger.LogError(
exception,
"Order processing failed.");
await Task.Delay(
TimeSpan.FromSeconds(5),
stoppingToken);
}
}
}
}In this example, each iteration creates a new scope and resolves an OrderProcessor. Scoped dependencies are disposed when the scope ends.
The processor should perform a bounded unit of work and accept the cancellation token. If an order cannot be completed, it should leave the order in a state that can be retried safely.
The delay in the exception handler prevents an immediate retry loop. In production, consider a bounded retry policy with backoff, maximum attempt counts, and monitoring for repeated failures.
Join the conversation! Your thoughts help the community grow.