Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 044 / 100

Background services in .NET: what developers should know

Long-running work without a separate worker project.

Expona Admin7 min read

Background services in .NET

When you need long-running work—cleanup tasks, periodic syncs, message processing—your first instinct might be to spin up a separate worker project. But .NET gives you a simpler path: background services. I've used them countless times across platforms to handle everything from cache refreshes to abandoned order cleanup without the overhead of managing another deployment unit.

In this article, I'll walk you through building reliable background services in .NET, handling the gotchas that trip up developers, and knowing when to keep things simple versus when you actually need that dedicated worker.

BackgroundService: The Core Pattern

A background service in .NET is a hosted service that runs continuously in your application lifetime. You inherit from BackgroundService, override ExecuteAsync, and loop until a cancellation token fires. The beauty is that it's managed by the host—no manual thread management, no fire-and-forget tasks that can orphan.

Let me show you a real example. I built a cleanup service for an e-commerce platform that purged abandoned carts every 10 minutes:

csharp
public class AbandonedCartCleanupService(IServiceScopeFactory scopeFactory)
    : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory = scopeFactory;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(10));
        
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                using var scope = _scopeFactory.CreateScope();
                var cartRepository = scope.ServiceProvider
                    .GetRequiredService<ICartRepository>();
                
                await cartRepository.DeleteAbandonedCartsAsync(
                    olderThan: DateTime.UtcNow.AddHours(-24),
                    cancellationToken: stoppingToken);
            }
            catch (Exception ex)
            {
                // Log but don't crash the service
                var logger = scope.ServiceProvider
                    .GetRequiredService<ILogger<AbandonedCartCleanupService>>();
                logger.LogError(ex, "Cleanup failed");
            }
        }
    }
}

The pattern is straightforward: create a PeriodicTimer with your desired interval, wait for ticks inside a loop that respects the stopping token, and execute your work. When the application shuts down, the token cancels, the loop exits cleanly, and your service terminates gracefully.

Notice I'm not using a plain Task.Delay loop. PeriodicTimer is more robust—it handles missed ticks and won't drift over time. In production, this matters for predictable resource usage.

Scopes Are Mandatory

This is where many developers stumble. A hosted service itself is registered as a singleton. If you try to inject a scoped service like DbContext directly into the constructor, you'll get a runtime error or worse—a singleton holding onto a scoped dependency, causing lifetime issues.

The solution: inject IServiceScopeFactory and create a new scope for each work iteration. Inside that scope, resolve your scoped services.

csharp
public class NotificationDispatchService(
    IServiceScopeFactory scopeFactory,
    ILogger<NotificationDispatchService> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
        
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                using var scope = _scopeFactory.CreateScope();
                var dbContext = scope.ServiceProvider
                    .GetRequiredService<ApplicationDbContext>();
                var emailService = scope.ServiceProvider
                    .GetRequiredService<IEmailService>();
                
                var pendingNotifications = await dbContext.Notifications
                    .Where(n => !n.Sent)
                    .Take(100)
                    .ToListAsync(stoppingToken);
                
                foreach (var notification in pendingNotifications)
                {
                    await emailService.SendAsync(notification.Email, 
                        notification.Subject, stoppingToken);
                    notification.Sent = true;
                }
                
                await dbContext.SaveChangesAsync(stoppingToken);
            }
            catch (OperationCanceledException)
            {
                // Expected on shutdown
                throw;
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Notification dispatch failed");
            }
        }
    }
}

Every 30 seconds, we create a fresh scope, fetch a DbContext, query pending notifications, send them, and dispose the scope. The next iteration gets a clean database connection. This prevents connection pool exhaustion and ensures each work unit is isolated.

Register it in your Program.cs:

csharp
builder.Services.AddHostedService<NotificationDispatchService>();

Exception Handling Matters

By default in .NET 6 and later, an unhandled exception in a background service crashes the entire host. I learned this the hard way during a trading platform migration when a null reference exception in a price update service took down the whole API.

Always wrap your work in a try-catch. Log the error. Continue looping. Let the application stay alive.

csharp
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
    
    while (await timer.WaitForNextTickAsync(stoppingToken))
    {
        try
        {
            await DoWorkAsync(stoppingToken);
        }
        catch (OperationCanceledException)
        {
            // Safe to ignore on shutdown
            throw;
        }
        catch (HttpRequestException ex)
        {
            _logger.LogWarning(ex, "API call failed, will retry next tick");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unexpected error in background service");
            // Could implement exponential backoff here if needed
        }
    }
}

private async Task DoWorkAsync(CancellationToken ct)
{
    using var scope = _scopeFactory.CreateScope();
    // your logic
}

For a ride-hailing platform I worked on, I added different handling per exception type. Network timeouts were logged as warnings; database errors were errors; validation failures were debugged. This made production troubleshooting far easier.

Multiple Instances and Distributed Work

Here's a critical consideration: if you deploy three replicas of your API, each one runs the background service independently. So if your cleanup job runs every 10 minutes, it actually runs three times every 10 minutes. For idempotent tasks like "delete old records," that's usually fine. For "send one notification per abandoned cart," you'll send three.

You have two options:

  1. Distributed locks: Use Redis or a database row to acquire a lock before work. Only one replica gets the lock, does the job, releases it. Others skip that tick.
  2. Dedicated worker: Move the service to a separate, single-replica deployment. Keep your API stateless.

For the e-commerce platform, I implemented distributed locks using Redis:

csharp
public class LockedCleanupService(
    IServiceScopeFactory scopeFactory,
    IDistributedCache cache,
    ILogger<LockedCleanupService> logger)
    : BackgroundService
{
    private const string LockKey = "abandoned-cart-cleanup-lock";
    private const int LockDurationSeconds = 5 * 60; // 5 minutes

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(10));
        
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                var lockValue = Guid.NewGuid().ToString();
                var lockAcquired = await cache.SetAsync(
                    LockKey, 
                    Encoding.UTF8.GetBytes(lockValue),
                    new DistributedCacheEntryOptions
                    {
                        AbsoluteExpirationRelativeToNow = 
                            TimeSpan.FromSeconds(LockDurationSeconds)
                    },
                    stoppingToken);
                
                if (lockAcquired)
                {
                    using var scope = _scopeFactory.CreateScope();
                    var cartRepository = scope.ServiceProvider
                        .GetRequiredService<ICartRepository>();
                    
                    await cartRepository.DeleteAbandonedCartsAsync(
                        olderThan: DateTime.UtcNow.AddHours(-24),
                        cancellationToken: stoppingToken);
                    
                    logger.LogInformation("Cleanup completed");
                }
                else
                {
                    logger.LogDebug("Another instance holds the lock, skipping");
                }
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Cleanup failed");
            }
        }
    }
}

This approach works well when you have a handful of replicas and the job isn't time-critical. For truly critical work—like processing payments or sending critical alerts—a dedicated single-replica worker is safer. You avoid the complexity of distributed locks and get predictable, isolated behavior.

Common Mistakes

  • Injecting scoped services directly: Your service is a singleton; injecting DbContext in the constructor is a lifetime mismatch. Always use IServiceScopeFactory.
  • Ignoring cancellation tokens: Pass stoppingToken to all async operations. If you block or ignore it, graceful shutdown becomes impossible.
  • Letting exceptions crash the host: Wrap everything. Log generously. A dead background service means a dead application.
  • Forgetting about multiple replicas: Test with two or three instances running simultaneously. If your job isn't idempotent, you need locking or a dedicated worker.
  • Using Task.Delay in tight loops: It works, but PeriodicTimer is more efficient and accurate for production workloads.
  • Not testing cancellation: In local development, you might never call CancellationToken.ThrowIfCancellationRequested()`. In production, graceful shutdown depends on it.

Key Takeaways

  • Background services in .NET let you run long-running work without a separate deployment—inherit BackgroundService, use PeriodicTimer, and loop until the stopping token fires.
  • Always inject IServiceScopeFactory and create a scope per work iteration to resolve scoped services like DbContext.
  • Catch exceptions and log them; letting one bubble up crashes your entire host.
  • When deploying multiple replicas, remember each runs the service independently. Use distributed locks for non-idempotent work, or move to a dedicated worker for critical jobs.
  • Test with cancellation tokens active to ensure graceful shutdown works as expected.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles