Introduction#
IHostedService and BackgroundService are the .NET primitives for running long-lived background work inside an ASP.NET Core process. They cover a wide range of patterns: Kafka consumers, periodic jobs, queue processors, health check daemons, and warm-up tasks. Understanding their lifecycle and proper shutdown handling is essential for reliable background processing.
IHostedService Interface#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| using Microsoft.Extensions.Hosting;
// IHostedService: the foundation
// StartAsync: called when the host starts
// StopAsync: called with a cancellation token when shutdown begins
public class DatabaseWarmupService : IHostedService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<DatabaseWarmupService> _logger;
public DatabaseWarmupService(
IServiceScopeFactory scopeFactory,
ILogger<DatabaseWarmupService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Warming up database connections...");
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.CanConnectAsync(cancellationToken);
_logger.LogInformation("Database warm-up complete.");
}
public Task StopAsync(CancellationToken cancellationToken)
{
// Nothing to clean up — warm-up is one-shot
return Task.CompletedTask;
}
}
// Register in DI
builder.Services.AddHostedService<DatabaseWarmupService>();
|
BackgroundService: Long-Running Worker#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
| // BackgroundService wraps IHostedService for continuous background work
// ExecuteAsync runs on a background thread and should loop until CancellationToken is cancelled
public class QueueProcessorService : BackgroundService
{
private readonly IMessageQueue _queue;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<QueueProcessorService> _logger;
public QueueProcessorService(
IMessageQueue queue,
IServiceScopeFactory scopeFactory,
ILogger<QueueProcessorService> logger)
{
_queue = queue;
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Queue processor started.");
await foreach (var message in _queue.ReadAllAsync(stoppingToken))
{
try
{
await ProcessMessageAsync(message, stoppingToken);
}
catch (OperationCanceledException)
{
// Shutdown requested — stop gracefully
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing message {MessageId}", message.Id);
// Continue processing other messages
}
}
_logger.LogInformation("Queue processor stopped.");
}
private async Task ProcessMessageAsync(Message message, CancellationToken ct)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var handler = scope.ServiceProvider.GetRequiredService<IMessageHandler>();
await handler.HandleAsync(message, ct);
}
}
|
Periodic Background Service#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| public class MetricsReporterService : BackgroundService
{
private readonly TimeSpan _interval;
private readonly IMetricsCollector _metrics;
private readonly ILogger<MetricsReporterService> _logger;
public MetricsReporterService(
IMetricsCollector metrics,
ILogger<MetricsReporterService> logger,
TimeSpan? interval = null)
{
_metrics = metrics;
_logger = logger;
_interval = interval ?? TimeSpan.FromMinutes(1);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Use PeriodicTimer — won't drift even if work takes longer than interval
using var timer = new PeriodicTimer(_interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await ReportMetricsAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to report metrics");
// Continue — next tick will retry
}
}
}
private async Task ReportMetricsAsync(CancellationToken ct)
{
var snapshot = await _metrics.CollectAsync(ct);
_logger.LogInformation(
"Metrics: requests={Requests} errors={Errors} p99={P99}ms",
snapshot.RequestCount,
snapshot.ErrorCount,
snapshot.P99LatencyMs
);
}
}
|
Kafka Consumer as Hosted Service#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
| using Confluent.Kafka;
public class OrderEventConsumer : BackgroundService
{
private readonly IConsumer<string, string> _consumer;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OrderEventConsumer> _logger;
public OrderEventConsumer(
IConfiguration config,
IServiceScopeFactory scopeFactory,
ILogger<OrderEventConsumer> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
var consumerConfig = new ConsumerConfig
{
BootstrapServers = config["Kafka:BootstrapServers"],
GroupId = "order-processor",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false,
};
_consumer = new ConsumerBuilder<string, string>(consumerConfig).Build();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_consumer.Subscribe("order-events");
try
{
while (!stoppingToken.IsCancellationRequested)
{
var result = _consumer.Consume(stoppingToken);
if (result?.Message is null) continue;
await using var scope = _scopeFactory.CreateAsyncScope();
var handler = scope.ServiceProvider.GetRequiredService<IOrderEventHandler>();
try
{
await handler.HandleAsync(result.Message.Value, stoppingToken);
_consumer.Commit(result);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to process {Topic}[{Partition}]@{Offset}",
result.Topic, result.Partition, result.Offset);
// Don't commit — message will be reprocessed
}
}
}
finally
{
_consumer.Close();
}
}
public override void Dispose()
{
_consumer.Dispose();
base.Dispose();
}
}
|
Graceful Shutdown Configuration#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // appsettings.json
{
"ShutdownTimeout": "00:00:30" // 30 seconds to finish in-flight work
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<HostOptions>(options =>
{
// Allow 30 seconds for hosted services to stop gracefully
options.ShutdownTimeout = TimeSpan.FromSeconds(30);
// If a hosted service throws in BackgroundService.ExecuteAsync,
// stop the entire host (fail fast) vs continue (ignore)
options.BackgroundServiceExceptionBehavior =
BackgroundServiceExceptionBehavior.StopHost;
});
builder.Services.AddHostedService<QueueProcessorService>();
builder.Services.AddHostedService<MetricsReporterService>();
builder.Services.AddHostedService<OrderEventConsumer>();
var app = builder.Build();
app.Run();
|
Worker Service (No HTTP)#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // For background workers that don't serve HTTP requests
// Uses Microsoft.Extensions.Hosting (not ASP.NET Core)
// Program.cs
using Microsoft.Extensions.Hosting;
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureServices((context, services) =>
{
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(context.Configuration.GetConnectionString("Default")));
services.AddSingleton<IMessageQueue, RabbitMQMessageQueue>();
services.AddScoped<IMessageHandler, OrderMessageHandler>();
services.AddHostedService<QueueProcessorService>();
services.AddHostedService<MetricsReporterService>();
})
.Build();
await host.RunAsync();
|
Health Checks for Background Services#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| using Microsoft.Extensions.Diagnostics.HealthChecks;
public class QueueProcessorHealthCheck : IHealthCheck
{
private readonly QueueProcessorService _service;
public QueueProcessorHealthCheck(QueueProcessorService service)
{
_service = service;
}
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
if (_service.IsRunning && _service.LastProcessedAt > DateTime.UtcNow.AddMinutes(-5))
{
return Task.FromResult(HealthCheckResult.Healthy("Processing normally"));
}
return Task.FromResult(HealthCheckResult.Unhealthy(
"Queue processor has not processed messages in > 5 minutes"
));
}
}
// Register
builder.Services.AddHealthChecks()
.AddCheck<QueueProcessorHealthCheck>("queue-processor");
|
Conclusion#
BackgroundService is the right base class for most long-running background work in .NET. Use IHostedService for one-shot startup tasks like warm-up. Use PeriodicTimer instead of Task.Delay in loops to prevent timer drift. Always respect the CancellationToken in ExecuteAsync to enable graceful shutdown. Create a new IServiceScope per message to avoid DbContext lifetime issues. Configure ShutdownTimeout to give in-flight messages enough time to complete before the process exits.