.NET Hosted Services and Background Workers: Long-Running Tasks

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,

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.

Contents