Skip to main content

Command Palette

Search for a command to run...

7 Common ASP.NET Core BackgroundService Mistakes (And How to Fix Them)

Updated
โ€ข10 min read

BackgroundService is one of the most useful primitives in ASP.NET Core โ€” and one of the most quietly dangerous. It runs outside the HTTP request pipeline, holds no implicit scope, and fails in ways that produce no error pages, no stack traces, and often no log entries at all. When a BackgroundService breaks in production, the application keeps returning 200 OK responses while the background work silently stops.

These patterns surface repeatedly in enterprise .NET codebases. The complete, production-ready reference implementation โ€” with structured error handling, graceful shutdown, and scope management โ€” is available on Patreon alongside annotated source code that maps directly to what production teams actually ship.

Understanding how BackgroundService fits into the ASP.NET Core hosting model is also the foundation of Chapter 12 of the Zero to Production course, which covers background jobs, the Outbox pattern, and Hangfire in the context of a complete production API.

ASP.NET Core Web API: Zero to Production

Here are seven mistakes teams make with BackgroundService in ASP.NET Core โ€” and the fixes that resolve them cleanly.


Mistake 1: Injecting Scoped Services Into a Singleton

This is the most common โ€” and most consequential โ€” BackgroundService mistake. BackgroundService registers as a singleton. Scoped services, such as DbContext, are created per HTTP request and are not safe for use across that boundary. Injecting a scoped service directly into a BackgroundService constructor will throw at runtime in development and behave silently incorrectly in production builds where scope validation is disabled.

Why it happens: The constructor feels like any other constructor. The DI container resolves it without complaint if validation is not enabled.

The fix: Never inject scoped services via constructor injection in a BackgroundService. Instead, inject IServiceScopeFactory and create a scope per unit of work, then dispose it when the work is done. This is exactly the pattern shown in the existing troubleshooting guide on cannot-resolve-scoped-service.

// โœ… Correct โ€” scope per work unit
using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

The scope should be opened, used, and disposed inside ExecuteAsync โ€” not stored as a field.


Mistake 2: Swallowing Exceptions in ExecuteAsync

When an unhandled exception propagates out of ExecuteAsync, .NET 6+ marks the service as Faulted and stops it โ€” but by default, the host does not crash the application. The service simply stops running. No alarm fires. CPU and memory stay healthy. Dashboards stay green.

Why it happens: Developers test with short-lived example implementations where exceptions are visible in console output. In production, with aggregated logs and multiple services, the failure disappears unless the logging is explicit.

The fix: Wrap the entire body of ExecuteAsync in a try/catch. Log the exception at Error level. Decide deliberately whether the service should restart (re-throw to let the host restart it, or implement your own retry loop) or degrade gracefully.

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    try
    {
        await RunLoopAsync(stoppingToken);
    }
    catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
    {
        // Normal shutdown โ€” not an error
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "BackgroundService {ServiceName} encountered a fatal error", nameof(MyWorker));
        throw; // Re-throw to signal the host
    }
}

The OperationCanceledException guard is essential โ€” catch it explicitly and treat it as normal shutdown, not a fault.


Mistake 3: Blocking in StartAsync Instead of ExecuteAsync

IHostedService.StartAsync is meant to be non-blocking and fast. All hosted services start sequentially โ€” if one StartAsync blocks, every subsequent service waits. This includes Kestrel itself, which means the application appears unresponsive until the blocking service finishes its startup logic.

Why it happens: Teams often scaffold BackgroundService by overriding StartAsync directly when they want to "start work as soon as the app boots." The intent is right; the placement is wrong.

The fix: BackgroundService.ExecuteAsync is the correct place for long-running work. It is called by the base StartAsync implementation and runs in the background without blocking other services. StartAsync should only contain initialization that must complete before work begins โ€” opening a channel, loading configuration from an external source โ€” and should be fast.


Mistake 4: Not Respecting the CancellationToken

The stoppingToken passed into ExecuteAsync is cancelled when the host is shutting down. If the service ignores it โ€” by using Task.Delay(TimeSpan.FromSeconds(30)) without passing the token, or by running loops that check no cancellation condition โ€” the application cannot shut down cleanly within its configured timeout.

In Kubernetes, this leads to SIGTERM being followed by SIGKILL when the pod does not terminate within the grace period. Any in-flight work that has not checkpointed is lost.

Why it happens: It is easy to forget to pass the token through, especially in nested calls or when using third-party client libraries that have their own timeouts.

The fix: Pass stoppingToken to every awaitable call that accepts a CancellationToken. For delay loops, use Task.Delay(interval, stoppingToken) or PeriodicTimer in .NET 6+, which accepts the token at each WaitForNextTickAsync call:

using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
    await ProcessBatchAsync(stoppingToken);
}

PeriodicTimer is the cleanest model for periodic background work in .NET 6 and later โ€” it does not drift, it respects cancellation, and it does not consume thread pool threads between ticks. See the CancellationToken Enterprise Decision Guide for the full pattern.


Mistake 5: Registering Multiple BackgroundService Instances That Share State

Teams sometimes create multiple BackgroundService subclasses that share a static field, a singleton collection, or another shared mutable state intended to coordinate their work. Two workers writing to and reading from the same field without synchronisation produces race conditions that show up as corrupted data, missed messages, or intermittent IndexOutOfRangeException crashes under load.

Why it happens: The intent is to allow one service to produce work and another to consume it. The shared state feels simple and low-ceremony.

The fix: Use System.Threading.Channels for producer/consumer coordination between background workers. Channel<T> is lock-free, supports back-pressure via bounded capacity, and passes ownership of items cleanly between producers and consumers without shared mutable state. Register the channel as a singleton in DI and inject it into both workers. The full walkthrough is in the background services reference implementation on GitHub.


Mistake 6: Using Task.Run Inside ExecuteAsync for Parallelism

ExecuteAsync runs on the thread pool โ€” it is already asynchronous. Wrapping work in Task.Run to "run it in parallel" does not add parallelism in the way most developers expect; it queues more work onto the thread pool, potentially starving it under load, and removes the structured relationship between the parent task and the child work items.

Why it happens: Task.Run works well in test environments and during local development where thread pool pressure is not a concern. The mistake is not visible until the production worker is processing thousands of messages per second.

The fix: For CPU-bound parallel work inside a background worker, use Parallel.ForEachAsync (.NET 6+) with a controlled degree of parallelism. For IO-bound parallelism, use Task.WhenAll with a semaphore or Channel<T> with multiple consumer tasks. The degree of parallelism should be a configured variable, not a hard-coded constant โ€” it needs to be tuned per deployment environment.


Mistake 7: Not Handling the Case Where the Service Is Still Running at Shutdown

ASP.NET Core's default shutdown timeout is 5 seconds. If the BackgroundService is in the middle of a long operation โ€” for example, processing a database batch, publishing messages, or waiting on an external HTTP call โ€” the host will forcibly cancel the token and proceed with shutdown after 5 seconds, regardless of whether the service has finished.

Why it happens: Most examples show BackgroundService implementations that finish each unit of work quickly. Teams replicate this pattern without considering what happens when the work unit takes 20 seconds and a deployment happens in the middle.

The fix: Design each work unit to be interruptible. Before starting a work unit, check stoppingToken.IsCancellationRequested. If a unit of work cannot be interrupted, checkpoint its progress so it can resume after the restart. For processing pipelines that must guarantee delivery, the Outbox pattern โ€” covered in the IHostedService vs BackgroundService Enterprise Decision Guide โ€” provides durability by separating message persistence from processing.

For longer shutdown windows, configure the host timeout explicitly in appsettings.json:

"ShutdownTimeout": "00:00:30"

Or programmatically:

builder.Services.Configure<HostOptions>(opts =>
    opts.ShutdownTimeout = TimeSpan.FromSeconds(30));

Match this to your actual work unit duration โ€” not to an arbitrary "feels safe" number.


What These Mistakes Have in Common

Each of these mistakes shares a root cause: BackgroundService runs outside the safety nets that protect request-handling code. There is no exception middleware. There is no request scope. There is no automatic retry. Every protection that ASP.NET Core provides for HTTP endpoints must be deliberately constructed for background workers.

The reward for getting this right is a service that is resilient, observable, and safe to deploy and restart at any time โ€” which is exactly what production workloads demand.


FAQ

What is the difference between IHostedService and BackgroundService in ASP.NET Core?

IHostedService is the interface you implement directly when you need full control over StartAsync and StopAsync. BackgroundService is an abstract base class that implements IHostedService and forwards long-running work to its ExecuteAsync abstract method. For most background tasks, BackgroundService is the right starting point because it handles the boilerplate and keeps your work in the correct lifecycle method.

Can I inject a DbContext directly into a BackgroundService?

No. DbContext is a scoped service and BackgroundService is a singleton. Direct constructor injection will throw an InvalidOperationException in development when scope validation is enabled. The correct pattern is to inject IServiceScopeFactory and create a scope per unit of work inside ExecuteAsync โ€” creating the DbContext inside that scope.

How do I prevent BackgroundService from silently stopping in production?

Wrap the entire body of ExecuteAsync in a try/catch. Log all exceptions at Error level. Re-throw fatal exceptions so the host marks the service as faulted. Configure your health checks or alerts to monitor service status โ€” a service marked Faulted should trigger the same alert as an unhealthy endpoint.

What is the best way to implement a recurring background task in .NET 10?

Use PeriodicTimer. It was introduced in .NET 6 and is the preferred model for fixed-interval background work. Unlike Task.Delay in a loop, PeriodicTimer does not drift between ticks โ€” it fires at the interval boundaries regardless of how long the previous work unit took. It also integrates cleanly with CancellationToken via WaitForNextTickAsync(token).

Should I use Hangfire instead of BackgroundService?

Depends on the workload. BackgroundService is appropriate for in-process, long-running, stateless tasks โ€” polling a queue, processing a channel, sending periodic notifications. Hangfire is appropriate when you need job persistence, retries, dashboards, fire-and-forget jobs, or scheduled triggers. If a job must survive an application restart without losing state, use Hangfire. If it can safely restart from scratch, BackgroundService is lighter and has no external dependencies.

How does CancellationToken work in BackgroundService?

The CancellationToken passed to ExecuteAsync as stoppingToken is triggered when the application host begins its shutdown sequence. Passing this token to every awaitable call ensures that async operations complete promptly during shutdown rather than blocking the host until the configured shutdown timeout expires. Always distinguish OperationCanceledException caused by the stopping token (expected, normal) from those caused by other cancellations (may be unexpected).

What happens if an exception escapes ExecuteAsync?

In .NET 6 and later, an unhandled exception in ExecuteAsync moves the service to Faulted state and stops it โ€” but does not crash the host by default. This means the application continues serving HTTP traffic while the background worker sits silently dead. Starting in .NET 8, you can configure HostOptions.BackgroundServiceExceptionBehavior to StopHost so the application shuts down when any background service faults, which makes the failure visible immediately rather than silent.

More from this blog

C

Coding Droplets

254 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.