Worker Services and the .NET Generic Host

Not everything is a web API. Queue processors, scheduled tasks, file watchers, and event stream consumers all need the same infrastructure as web applications — dependency injection, configuration, logging, graceful shutdown — without the HTTP pipeline. That's where the .NET Generic Host and worker services come in.

The Generic Host

The Generic Host (Microsoft.Extensions.Hosting) provides the foundational infrastructure that ASP.NET Core's WebApplication is built on. When you create a worker service, you get the same capabilities:

Program.cs
var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddHostedService<QueueProcessor>();

var host = builder.Build();
host.Run();

This gives you:

Creating a worker service

Scaffold one with the template:

dotnet new worker -n MyWorker

The template generates a Worker class that extends BackgroundService:

Example.cs
public class Worker : BackgroundService
{
    private readonly ILogger<Worker> _logger;

    public Worker(ILogger<Worker> logger)
    {
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logger.LogInformation("Worker running at: {Time}", DateTimeOffset.Now);
            await Task.Delay(1000, stoppingToken);
        }
    }
}

ExecuteAsync is called once when the host starts. The stoppingToken is triggered when the host receives a shutdown signal (Ctrl+C, SIGTERM, or IHostApplicationLifetime.StopApplication()).

Pattern: queue processor

A common pattern is consuming messages from a queue:

Example.cs
public class OrderProcessor : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OrderProcessor> _logger;

    public OrderProcessor(
        IServiceScopeFactory scopeFactory,
        ILogger<OrderProcessor> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Order processor starting");

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await using var scope = _scopeFactory.CreateAsyncScope();
                var queue = scope.ServiceProvider.GetRequiredService<IOrderQueue>();
                var handler = scope.ServiceProvider.GetRequiredService<IOrderHandler>();

                var order = await queue.DequeueAsync(stoppingToken);
                if (order is not null)
                {
                    await handler.ProcessAsync(order, stoppingToken);
                }
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                // Graceful shutdown, not an error
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error processing order");
                await Task.Delay(5000, stoppingToken); // Back-off on failure
            }
        }
    }
}

Key points:

Pattern: scheduled task

For periodic tasks, use a PeriodicTimer:

Example.cs
public class ReportGenerator : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<ReportGenerator> _logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(1));

        // Run immediately on startup, then hourly
        do
        {
            try
            {
                await using var scope = _scopeFactory.CreateAsyncScope();
                var generator = scope.ServiceProvider.GetRequiredService<IReportService>();
                await generator.GenerateHourlyReportAsync(stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                _logger.LogError(ex, "Failed to generate hourly report");
            }
        }
        while (await timer.WaitForNextTickAsync(stoppingToken));
    }

    public ReportGenerator(
        IServiceScopeFactory scopeFactory,
        ILogger<ReportGenerator> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }
}

PeriodicTimer is preferable to Task.Delay for periodic work because it doesn't drift — it accounts for the execution time of each tick.

Multiple hosted services

Register as many as you need. They start in registration order and stop in reverse order:

Example.cs
builder.Services.AddHostedService<QueueProcessor>();
builder.Services.AddHostedService<ReportGenerator>();
builder.Services.AddHostedService<HealthMonitor>();

// IMPORTANT

ExecuteAsync must not block. If your service does CPU-intensive work, wrap it in Task.Run so it doesn't delay the startup of subsequent hosted services.

Graceful shutdown

The host gives each service a configurable window to shut down:

Example.cs
builder.Services.Configure<HostOptions>(options =>
{
    options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});

Within your ExecuteAsync, respect the stoppingToken. If your service is processing a batch, complete the current item and exit rather than abandoning mid-operation.

For cleanup that must happen after ExecuteAsync returns, override StopAsync:

Example.cs
public override async Task StopAsync(CancellationToken cancellationToken)
{
    _logger.LogInformation("Draining remaining items...");
    await _channel.Writer.TryComplete();
    await base.StopAsync(cancellationToken);
}

Running as a system service

Worker services can run as Windows Services or Linux systemd daemons:

Example.cs
// Windows Service
builder.Services.AddWindowsService(options =>
{
    options.ServiceName = "OrderProcessor";
});

// Linux systemd
builder.Services.AddSystemd();

Install the corresponding NuGet packages:

dotnet add package Microsoft.Extensions.Hosting.WindowsServices
dotnet add package Microsoft.Extensions.Hosting.Systemd

Both adapt the host to the system service lifecycle, handling start, stop, and status reporting automatically.

Wrapping up

The Generic Host provides everything you need for non-HTTP workloads: DI, configuration, logging, and lifecycle management. BackgroundService handles the most common pattern — a long-running loop with graceful shutdown. Create scopes for each unit of work, respect the cancellation token, and handle errors with backoff. That's all it takes to build production-ready background processors in .NET.