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

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](https://www.patreon.com/CodingDroplets) 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](https://aspnetcoreapi.codingdroplets.com/), 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](https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg align="center")](https://aspnetcoreapi.codingdroplets.com/)

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](https://codingdroplets.com/cannot-resolve-scoped-service-root-provider-aspnet-core).

```csharp
// ✅ 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.

```csharp
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:

```csharp
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](https://codingdroplets.com/cancellationtoken-aspnet-core-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](https://github.com/codingdroplets/dotnet-background-services-hostedservice).

* * *

## 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](https://codingdroplets.com/aspnet-core-ihostedservice-vs-backgroundservice-worker-service-enterprise-decision-guide) — provides durability by separating message persistence from processing.

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

```json
"ShutdownTimeout": "00:00:30"
```

Or programmatically:

```csharp
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.
