ASP.NET Core Background Services: How to Run Reliable Background Tasks in .NET
Not every task in a web application should run inside an HTTP request.
Sending emails, processing files, generating reports, consuming messages from a queue, cleaning old records, and synchronizing data are all examples of work that can run in the background.
If we perform these operations directly inside a controller, we can create slow requests, unnecessary timeouts, and poor user experiences.
ASP.NET Core provides a clean solution for this through BackgroundService and IHostedService.
In this tutorial, we'll build a practical background worker, understand how it works, and look at some production considerations.
Why Do We Need Background Services?
Consider an API endpoint that generates a large report.
A simple implementation might look like this:
[HttpPost("generate-report")]
public async Task<IActionResult> GenerateReport()
{
await GenerateLargeReportAsync();
return Ok();
}
The client has to wait until the entire report is generated.
If the operation takes 30 seconds, the HTTP request remains open for 30 seconds.
That's not ideal.
A better architecture is:
Client
↓
API
↓
Queue background work
↓
Return response
↓
Background Worker
↓
Generate Report
The API can respond quickly while the background worker handles the long-running operation.
BackgroundService vs IHostedService
ASP.NET Core provides two common ways to implement background work.
IHostedService
IHostedService is the lower-level interface.
It provides:
StartAsync()
StopAsync()
You have more control, but you also need to manage more of the implementation yourself.
BackgroundService
BackgroundService is an abstract class that implements IHostedService.
It is usually easier when your worker needs to continuously run background operations.
For many applications, BackgroundService is the simplest choice.
1. Create Your First Background Service
Create a class:
public class MyBackgroundService : BackgroundService
{
private readonly ILogger<MyBackgroundService> _logger;
public MyBackgroundService(
ILogger<MyBackgroundService> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation(
"Background service is running.");
await Task.Delay(
TimeSpan.FromSeconds(10),
stoppingToken);
}
}
}
The important method is:
ExecuteAsync()
This method runs when the hosted service starts.
The CancellationToken tells your worker when the application is shutting down.
2. Register the Background Service
Register it in Program.cs:
builder.Services.AddHostedService<MyBackgroundService>();
That's it.
When your ASP.NET Core application starts, the background service starts with it.
When the application shuts down, ASP.NET Core signals the cancellation token.
3. Run a Task on a Schedule
Suppose you need to perform a cleanup operation every hour.
You could write:
public class CleanupService : BackgroundService
{
private readonly ILogger<CleanupService> _logger;
public CleanupService(
ILogger<CleanupService> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(
TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync(
stoppingToken))
{
try
{
_logger.LogInformation(
"Starting cleanup.");
await CleanupAsync(stoppingToken);
_logger.LogInformation(
"Cleanup completed.");
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error occurred during cleanup.");
}
}
}
private async Task CleanupAsync(
CancellationToken cancellationToken)
{
// Cleanup logic
await Task.CompletedTask;
}
}
PeriodicTimer makes scheduled asynchronous work easier to manage.
4. Why CancellationToken Matters
One of the most important things in a background service is graceful shutdown.
Avoid this:
while (true)
{
await DoWorkAsync();
}
The worker has no way to respond to application shutdown.
Instead:
while (!stoppingToken.IsCancellationRequested)
{
await DoWorkAsync(stoppingToken);
}
Now ASP.NET Core can tell the worker:
"The application is shutting down."
The worker can stop gracefully.
5. Don't Block the Thread
Avoid blocking calls such as:
Thread.Sleep(5000);
inside asynchronous background work.
Prefer:
await Task.Delay(
TimeSpan.FromSeconds(5),
stoppingToken);
This allows the thread to be used efficiently while waiting.
The same principle applies to database and HTTP operations.
Prefer:
await repository.GetDataAsync(
stoppingToken);
instead of synchronous operations that block a thread.
6. Using Dependency Injection in Background Services
A common requirement is to use a service from your dependency-injection container.
For example:
public class OrderProcessingService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public OrderProcessingService(
IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope =
_scopeFactory.CreateScope();
var orderService =
scope.ServiceProvider
.GetRequiredService<IOrderService>();
await orderService.ProcessOrdersAsync(
stoppingToken);
await Task.Delay(
TimeSpan.FromMinutes(1),
stoppingToken);
}
}
}
This is important because background services are generally registered as singletons.
If the service you need is scoped, create a scope before resolving it.
7. BackgroundService and Entity Framework Core
Suppose your application needs to periodically process database records.
You might have:
public class OrderProcessingWorker
: BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OrderProcessingWorker> _logger;
public OrderProcessingWorker(
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessingWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope =
_scopeFactory.CreateScope();
var dbContext =
scope.ServiceProvider
.GetRequiredService<AppDbContext>();
var pendingOrders =
await dbContext.Orders
.Where(x => x.Status == "Pending")
.ToListAsync(stoppingToken);
foreach (var order in pendingOrders)
{
order.Status = "Processed";
}
await dbContext.SaveChangesAsync(
stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error processing orders.");
}
await Task.Delay(
TimeSpan.FromMinutes(1),
stoppingToken);
}
}
}
The important part is that the database context is resolved inside a scope.
Don't keep a scoped DbContext alive for the entire lifetime of the background service.
8. Process Work from a Queue
A more scalable approach is to use a queue.
For example:
API Request
↓
Add work to queue
↓
Background Worker
↓
Process work
A simple in-memory queue can use:
Channel<T>
For example:
public interface IBackgroundTaskQueue
{
ValueTask QueueAsync(
Func<CancellationToken, ValueTask> workItem);
ValueTask<Func<CancellationToken, ValueTask>>
DequeueAsync(
CancellationToken cancellationToken);
}
You can implement the queue using Channel.
This allows the API to add work without performing the expensive operation during the request.
9. Example: Queue an Email
Imagine an API that needs to send an email after creating an account.
Instead of:
await emailService.SendEmailAsync();
inside the HTTP request, you can queue the operation.
The flow becomes:
POST /api/users
↓
Create user
↓
Queue email
↓
Return 201 Created
↓
Background worker
↓
Send email
This can make the API response much faster.
10. Important: In-Memory Queues Have a Limitation
An in-memory queue is simple, but it has an important limitation.
If your application restarts:
Application
↓
Memory queue
↓
Application crashes
The queued work can be lost.
That's why in-memory background queues are generally more suitable for simple tasks where losing queued work is acceptable.
For important business operations, consider a durable messaging system such as:
- Azure Service Bus
- RabbitMQ
- Amazon SQS
- Kafka
- Other persistent queue technologies
The choice depends on your architecture and reliability requirements.
11. Background Services Don't Automatically Scale Like API Requests
Suppose you deploy your API with five instances:
Instance 1
Instance 2
Instance 3
Instance 4
Instance 5
If each instance runs the same BackgroundService, you now have five workers.
That may be completely fine for some workloads.
But it can also create duplicate processing.
For example, you might accidentally have:
Instance 1 → Process order #100
Instance 2 → Process order #100
Instance 3 → Process order #100
Now the same order may be processed multiple times.
For distributed applications, you may need:
- Distributed locks
- Database coordination
- Queue-based processing
- Idempotent operations
- A dedicated worker service
12. Make Background Operations Idempotent
Idempotency is extremely important for background processing.
Suppose a payment operation is accidentally executed twice.
That's a serious problem.
Instead of assuming:
Process once
design your operation so repeated execution doesn't create duplicate side effects.
For example:
if (await IsAlreadyProcessedAsync(orderId))
{
return;
}
await ProcessOrderAsync(orderId);
await MarkAsProcessedAsync(orderId);
This pattern becomes especially important when working with queues and distributed systems.
13. Handle Exceptions Carefully
Don't allow an exception to silently terminate important background processing.
Use logging:
try
{
await ProcessAsync(stoppingToken);
}
catch (OperationCanceledException)
when (stoppingToken.IsCancellationRequested)
{
// Application is shutting down.
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Background processing failed.");
}
This allows expected shutdown cancellation to be treated differently from actual failures.
14. Add Useful Logging
Good background workers should provide enough information to troubleshoot problems.
For example:
_logger.LogInformation(
"Starting order processing.");
_logger.LogInformation(
"Found {Count} pending orders.",
orders.Count);
_logger.LogError(
exception,
"Failed to process OrderId {OrderId}",
orderId);
Avoid logging sensitive information such as passwords, tokens, or payment information.
15. When Should You Use a BackgroundService?
BackgroundService is a good choice for tasks such as:
- Periodic cleanup
- Cache refresh
- Scheduled synchronization
- Processing lightweight background work
- Polling an external system
- Internal application maintenance
- Simple queue processing
But it may not be the best choice for every workload.
If your application requires:
- Guaranteed delivery
- High-volume processing
- Distributed workers
- Retry policies
- Dead-letter queues
- Persistent messages
a dedicated message broker or background-job system may be more appropriate.
A Practical Architecture
A production application might look like this:
Client
↓
ASP.NET Core API
↓
Durable Queue
↓
Worker Service
↓
Database / External Services
The API handles HTTP requests.
The queue stores work reliably.
The worker processes the work.
This separation makes the system easier to scale.
BackgroundService Checklist
Before deploying a background worker, ask:
- [ ] Does the worker support graceful cancellation?
- [ ] Are async APIs used instead of blocking calls?
- [ ] Are scoped dependencies resolved inside a scope?
- [ ] Are exceptions logged?
- [ ] Can the operation safely be retried?
- [ ] Is the operation idempotent?
- [ ] Can work be lost if the application restarts?
- [ ] Will multiple application instances process the same work?
- [ ] Do you need a durable queue?
- [ ] Do you have monitoring for failed background jobs?
Final Thoughts
Background processing is an important part of building production-ready ASP.NET Core applications.
The key is to avoid putting every operation inside an HTTP request.
Use BackgroundService for appropriate application-level background work.
For critical or distributed workloads, consider moving toward a durable queue and dedicated worker architecture.
A useful mental model is:
Fast API request
+
Reliable background processing
=
Better application architecture
The goal isn't simply to move code into a background thread.
The goal is to design background work that is reliable, observable, cancellable, and safe to retry.
A Small Developer Tool for Everyday .NET Work
While working with APIs and background services, developers often need quick utilities for formatting JSON, decoding JWTs, comparing API responses, converting data formats, and handling other small development tasks.
That's why I built ToolBench — a collection of free browser-based developer tools designed for everyday development work.
Explore the tools here:
It can be a handy companion when you're debugging APIs, working with JSON, or troubleshooting development issues.
What kind of background processing do you use in your .NET applications?
BackgroundService, Azure Service Bus, Hangfire, RabbitMQ, or something else?
Share your approach in the comments.
Top comments (1)
The idempotency example still has a check-then-act race: two replicas can both pass
IsAlreadyProcessedAsync, while a crash after the external side effect but beforeMarkAsProcessedAsynccauses a replay. For database-backed work, I’d claim the row atomically—usingUPDATE ... WHERE Status = 'Pending' OUTPUT INSERTED.Idor an EF concurrency token—then carry a stable idempotency key to the external boundary. Add a lease timestamp for abandoned claims. If the side effect and status update cannot share a transaction, the honest guarantee is at-least-once; test crashes after claim, after the external call, and before acknowledgement.