Back to blog
Backend Systemsintermediate

Background Services & Hosted Services in .NET

Build long-running background tasks in ASP.NET Core with IHostedService and BackgroundService — scheduled jobs, queue workers, health-monitored services, and the Outbox pattern.

LearnixoApril 13, 20267 min read
.NETC#Background ServicesIHostedServiceASP.NET CoreQueues
Share:𝕏

IHostedService vs BackgroundService

| | IHostedService | BackgroundService | |---|---|---| | What it is | Interface with StartAsync / StopAsync | Abstract class implementing IHostedService | | Use when | You need full control over start/stop lifecycle | You need a long-running loop | | Main method | StartAsync / StopAsync | ExecuteAsync(CancellationToken) | | Error handling | Manual | Loop keeps running if you handle exceptions |

BackgroundService implements IHostedService under the hood — it's the easier starting point for most background work.


BackgroundService: The Basics

C#
public abstract class BackgroundService : IHostedService, IDisposable
{
    protected abstract Task ExecuteAsync(CancellationToken stoppingToken);

    public virtual Task StartAsync(CancellationToken cancellationToken)
    {
        // Starts ExecuteAsync on a background thread
        _ = ExecuteAsync(stoppingToken);
        return Task.CompletedTask;
    }

    public virtual Task StopAsync(CancellationToken cancellationToken)
    {
        // Signals stoppingToken cancellation
        ...
    }
}

Your job: implement ExecuteAsync. When the stoppingToken is cancelled (app shutdown), the loop exits cleanly.


Pattern 1: Polling Worker

Polls a data source on a fixed interval:

C#
public class OrderSyncWorker : BackgroundService
{
    private readonly ILogger<OrderSyncWorker> _logger;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly TimeSpan _interval = TimeSpan.FromMinutes(5);

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

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

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await DoWorkAsync(stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                // Normal shutdown — don't log as error
                break;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error during order sync. Retrying in {Interval}.", _interval);
            }

            await Task.Delay(_interval, stoppingToken);
        }

        _logger.LogInformation("OrderSyncWorker stopping.");
    }

    private async Task DoWorkAsync(CancellationToken ct)
    {
        // BackgroundService is singleton; DbContext is scoped
        // Always create a scope for scoped dependencies
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        var syncService = scope.ServiceProvider.GetRequiredService<IOrderSyncService>();

        var pendingOrders = await db.Orders
            .Where(o => o.Status == OrderStatus.Pending && o.SyncedAt == null)
            .Take(100)
            .ToListAsync(ct);

        _logger.LogInformation("Syncing {Count} orders.", pendingOrders.Count);

        foreach (var order in pendingOrders)
        {
            await syncService.SyncToExternalSystemAsync(order, ct);
        }

        await db.SaveChangesAsync(ct);
    }
}

Critical: background services are registered as singletons, but DbContext is scoped. Never inject DbContext directly into a background service — always use IServiceScopeFactory.


Pattern 2: Scheduled Job (Cron-Style)

Run a job at a specific time each day:

C#
public class DailyReportWorker : BackgroundService
{
    private readonly ILogger<DailyReportWorker> _logger;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly TimeOnly _runAt = new(2, 0);  // 2:00 AM UTC

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

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var now = TimeOnly.FromDateTime(DateTime.UtcNow);
            var delay = CalculateDelayUntil(_runAt, now);

            _logger.LogInformation("Next report run in {Delay}.", delay);

            await Task.Delay(delay, stoppingToken);

            if (stoppingToken.IsCancellationRequested) break;

            try
            {
                await GenerateReportAsync(stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to generate daily report.");
            }
        }
    }

    private static TimeSpan CalculateDelayUntil(TimeOnly target, TimeOnly current)
    {
        var delay = target - current;
        if (delay < TimeSpan.Zero) delay += TimeSpan.FromHours(24);
        return delay;
    }

    private async Task GenerateReportAsync(CancellationToken ct)
    {
        using var scope = _scopeFactory.CreateScope();
        var reporter = scope.ServiceProvider.GetRequiredService<IReportService>();
        await reporter.GenerateDailyReportAsync(DateTime.UtcNow.Date.AddDays(-1), ct);
        _logger.LogInformation("Daily report generated.");
    }
}

Pattern 3: Channel-Based Queue Worker

Process work items from an in-memory queue. The Channel<T> class from System.Threading.Channels is the modern, high-performance producer/consumer channel.

Background Queue

C#
// Application/Common/Interfaces/IBackgroundTaskQueue.cs
public interface IBackgroundTaskQueue
{
    ValueTask QueueAsync(Func<IServiceProvider, CancellationToken, ValueTask> workItem);
    ValueTask<Func<IServiceProvider, CancellationToken, ValueTask>> DequeueAsync(CancellationToken ct);
}
C#
// Infrastructure/Background/BackgroundTaskQueue.cs
public class BackgroundTaskQueue : IBackgroundTaskQueue
{
    private readonly Channel<Func<IServiceProvider, CancellationToken, ValueTask>> _queue;

    public BackgroundTaskQueue(int capacity = 100)
    {
        _queue = Channel.CreateBounded<Func<IServiceProvider, CancellationToken, ValueTask>>(
            new BoundedChannelOptions(capacity)
            {
                FullMode = BoundedChannelFullMode.Wait
            }
        );
    }

    public async ValueTask QueueAsync(Func<IServiceProvider, CancellationToken, ValueTask> workItem)
        => await _queue.Writer.WriteAsync(workItem);

    public async ValueTask<Func<IServiceProvider, CancellationToken, ValueTask>> DequeueAsync(
        CancellationToken ct)
        => await _queue.Reader.ReadAsync(ct);
}

Queue Worker

C#
public class QueuedHostedService : BackgroundService
{
    private readonly IBackgroundTaskQueue _queue;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<QueuedHostedService> _logger;

    public QueuedHostedService(
        IBackgroundTaskQueue queue,
        IServiceScopeFactory scopeFactory,
        ILogger<QueuedHostedService> logger)
    {
        _queue = queue;
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var workItem = await _queue.DequeueAsync(stoppingToken);

            try
            {
                using var scope = _scopeFactory.CreateScope();
                await workItem(scope.ServiceProvider, stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error processing background queue item.");
            }
        }
    }
}

Enqueueing Work from a Controller

C#
[HttpPost("{id}/process")]
public async Task<IActionResult> ProcessOrder(int id)
{
    // Return 202 immediately, process in background
    await _queue.QueueAsync(async (sp, ct) =>
    {
        var processor = sp.GetRequiredService<IOrderProcessor>();
        await processor.ProcessAsync(id, ct);
    });

    return Accepted(new { message = "Order processing queued." });
}

Pattern 4: Outbox Pattern Worker

The Outbox pattern guarantees that domain events are published even if the message broker is unavailable at write time. Events are stored in the DB atomically with the business data, then a background worker relays them to the bus.

C#
// Domain event stored in DB
public class OutboxMessage
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Type { get; set; } = default!;
    public string Payload { get; set; } = default!;  // JSON
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public DateTime? ProcessedAt { get; set; }
    public string? Error { get; set; }
    public int RetryCount { get; set; }
}
C#
// Background worker that processes outbox messages
public class OutboxWorker : BackgroundService
{
    private readonly ILogger<OutboxWorker> _logger;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly TimeSpan _interval = TimeSpan.FromSeconds(10);

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

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await ProcessOutboxMessagesAsync(stoppingToken);
            await Task.Delay(_interval, stoppingToken);
        }
    }

    private async Task ProcessOutboxMessagesAsync(CancellationToken ct)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();

        var messages = await db.OutboxMessages
            .Where(m => m.ProcessedAt == null && m.RetryCount < 5)
            .OrderBy(m => m.CreatedAt)
            .Take(20)
            .ToListAsync(ct);

        foreach (var message in messages)
        {
            try
            {
                var eventType = Type.GetType(message.Type)
                    ?? throw new InvalidOperationException($"Unknown event type: {message.Type}");

                var payload = JsonSerializer.Deserialize(message.Payload, eventType)!;

                await bus.PublishAsync(payload, ct);

                message.ProcessedAt = DateTime.UtcNow;
                _logger.LogInformation("Published outbox message {Id} of type {Type}.", message.Id, message.Type);
            }
            catch (Exception ex)
            {
                message.RetryCount++;
                message.Error = ex.Message;
                _logger.LogWarning(ex, "Failed to publish outbox message {Id}.", message.Id);
            }
        }

        await db.SaveChangesAsync(ct);
    }
}

IHostedService: Full Lifecycle Control

Use raw IHostedService when you need to start/stop external resources:

C#
public class TcpListenerService : IHostedService
{
    private TcpListener? _listener;
    private Task? _listenTask;
    private readonly CancellationTokenSource _cts = new();
    private readonly ILogger<TcpListenerService> _logger;

    public TcpListenerService(ILogger<TcpListenerService> logger) => _logger = logger;

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _listener = new TcpListener(IPAddress.Any, 2575);  // MLLP port
        _listener.Start();

        _listenTask = AcceptConnectionsAsync(_cts.Token);

        _logger.LogInformation("TCP listener started on port 2575.");
        return Task.CompletedTask;
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        _cts.Cancel();
        _listener?.Stop();

        if (_listenTask is not null)
            await Task.WhenAny(_listenTask, Task.Delay(5000, cancellationToken));

        _logger.LogInformation("TCP listener stopped.");
    }

    private async Task AcceptConnectionsAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try
            {
                var client = await _listener!.AcceptTcpClientAsync(ct);
                _ = HandleClientAsync(client, ct);  // fire-and-forget per connection
            }
            catch (OperationCanceledException) { break; }
        }
    }

    private async Task HandleClientAsync(TcpClient client, CancellationToken ct)
    {
        using (client)
        {
            // handle HL7/MLLP messages...
        }
    }
}

Registration

C#
// Program.cs

// Register as hosted services
builder.Services.AddHostedService<OrderSyncWorker>();
builder.Services.AddHostedService<DailyReportWorker>();
builder.Services.AddHostedService<QueuedHostedService>();
builder.Services.AddHostedService<OutboxWorker>();

// The in-memory queue as singleton (shared between producer controllers and consumer worker)
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();

Health Checks for Background Services

Report background service health to the /health endpoint:

C#
public class OrderSyncWorkerHealthCheck : IHealthCheck
{
    private readonly OrderSyncWorker _worker;

    public OrderSyncWorkerHealthCheck(OrderSyncWorker worker) => _worker = worker;

    public Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken ct = default)
    {
        return Task.FromResult(
            _worker.IsHealthy
                ? HealthCheckResult.Healthy("Order sync worker is running.")
                : HealthCheckResult.Unhealthy("Order sync worker has stopped unexpectedly.")
        );
    }
}
C#
builder.Services.AddHealthChecks()
    .AddCheck<OrderSyncWorkerHealthCheck>("order-sync-worker");

app.MapHealthChecks("/health");

Key Takeaways

  • BackgroundService is the standard starting point — implement ExecuteAsync with a loop and cancel on stoppingToken
  • Background services are singleton — always use IServiceScopeFactory to resolve scoped services like DbContext
  • Catch all exceptions inside the loop — an uncaught exception terminates the worker silently
  • Channel<T> is the correct producer/consumer queue for in-process background work — don't use ConcurrentQueue + polling
  • The Outbox pattern solves the dual-write problem: write to DB and publish an event atomically — relay via a background worker
  • Use IHostedService directly when you need to manage external resources (TCP listeners, timers, connection pools)
  • Add health checks to every background service so Kubernetes/load balancers can detect stuck workers

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.