Introduction
Imagine you are building an ASP.NET Core Web API for an online shopping application. When a customer places an order, the API needs to save the order, send a confirmation email, update inventory information, and notify another service.
If the API performs every operation before returning a response, the customer may have to wait longer than necessary. If several requests arrive at the same time, the application may also struggle to handle the additional work.
Some operations do not need to finish before the API responds. Sending emails, generating reports, processing uploaded files, and running scheduled maintenance tasks are common examples.
ASP.NET Core provides several ways to run background work without making the request wait for the entire operation.
In this article, we will learn how to run background tasks using BackgroundService, queue work for later processing, handle dependency injection correctly, and avoid common problems that can affect production applications.
What Is Background Processing in ASP.NET Core?
Background processing means running work outside the immediate request-response flow.
Instead of making an API wait for every operation to finish, the application can accept a task and process it separately.
For example, consider an API that generates a monthly sales report. Generating the report might involve retrieving thousands of records, calculating totals, and creating a file.
If the client does not need the report immediately, the API can accept the request and process the report in the background.
A typical workflow looks like this:
The client sends a request to the API.
The API validates the request and saves the required information.
The application queues the background work.
The API returns a response.
A background worker processes the queued task.
This approach can improve responsiveness, but it does not mean every operation should run in the background. If the client needs the result immediately, the operation may still belong in the request flow.
Background Tasks vs. Fire-and-Forget Tasks
You may have seen code like this:
_ = SendEmailAsync();
Although this starts an asynchronous operation, it does not provide reliable background task management.
The application does not explicitly track the operation's lifetime, handle its failures, or ensure that it finishes before the process shuts down.
A safer approach is to use a managed background service or a queue that a background worker processes.
Approach | Suitable use | Main concern |
|---|
Direct asynchronous call | Work required before returning the response | Increases request duration |
Fire-and-forget task | Generally avoid for important work | Failures and task lifetime are difficult to manage |
BackgroundService
| Long-running workers and polling tasks | Requires lifecycle and shutdown handling |
Queued background processing | Work that can be handled after accepting a request | An in-memory queue does not survive application restarts |
Durable job queue | Important work requiring persistence and recovery | Requires additional infrastructure |
Step 1: Create a Background Service
ASP.NET Core includes the BackgroundService base class for implementing hosted background workers.
Create a class named NotificationWorker.cs.
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public class NotificationWorker : BackgroundService
{
private readonly ILogger<NotificationWorker> _logger;
public NotificationWorker(
ILogger<NotificationWorker> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation(
"Background worker is running at {Time}",
DateTimeOffset.UtcNow);
await Task.Delay(
TimeSpan.FromSeconds(30),
stoppingToken);
}
}
}
Let's understand how this worker operates.
ExecuteAsync() contains the background task's main logic. ASP.NET Core starts this method when the hosted service starts.
The stoppingToken signals that the application is shutting down. Passing it to Task.Delay() allows the delay to stop when cancellation is requested.
The worker repeats its operation until shutdown begins.
This example is useful for demonstrating the worker lifecycle. A real application would replace the logging statement with meaningful work, such as processing pending jobs or checking for scheduled tasks.
Step 2: Register the Background Worker
Open Program.cs and register the worker with dependency injection.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddHostedService<NotificationWorker>();
var app = builder.Build();
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
The following registration is important:
builder.Services.AddHostedService<NotificationWorker>();
It registers the worker as a hosted service so that ASP.NET Core manages its startup and shutdown.
You do not need to create the worker manually or start it from a controller. The hosting infrastructure handles its lifecycle.
Keep in mind that a hosted service normally runs within the same application process as your API. If the application stops, the worker stops too.
Step 3: Queue Work Instead of Running It During a Request
A background worker becomes more useful when an API can submit work to it.
For this example, we will create an in-memory queue using Channel<T>. Channels provide an asynchronous way to pass work between producers and consumers.
First, define a small service for managing the queue.
using System.Threading.Channels;
public sealed class BackgroundTaskQueue
{
private readonly Channel<Func<CancellationToken, Task>> _queue;
public BackgroundTaskQueue()
{
_queue = Channel.CreateBounded<Func<CancellationToken, Task>>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
SingleWriter = false
});
}
public ValueTask QueueAsync(
Func<CancellationToken, Task> workItem,
CancellationToken cancellationToken = default)
{
return _queue.Writer.WriteAsync(
workItem,
cancellationToken);
}
public ValueTask<Func<CancellationToken, Task>> DequeueAsync(
CancellationToken cancellationToken)
{
return _queue.Reader.ReadAsync(cancellationToken);
}
}
The queue has a capacity of 100 pending items. A bounded queue helps prevent the application from accumulating an unlimited number of tasks in memory.
When the queue is full, WriteAsync() waits for capacity to become available. The API should account for that wait and configure an appropriate request timeout or cancellation policy.
Next, create a worker that processes queued tasks.
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public sealed class QueuedTaskWorker : BackgroundService
{
private readonly BackgroundTaskQueue _queue;
private readonly ILogger<QueuedTaskWorker> _logger;
public QueuedTaskWorker(
BackgroundTaskQueue queue,
ILogger<QueuedTaskWorker> logger)
{
_queue = queue;
_logger = logger;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var workItem = await _queue.DequeueAsync(
stoppingToken);
await workItem(stoppingToken);
}
catch (OperationCanceledException)
when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Background task failed.");
}
}
}
}
The worker waits until a task becomes available, executes it, and continues processing the queue.
If one task throws an exception, the worker logs the failure and continues with the next task. This protects the worker loop from ordinary task failures, but it does not retry the failed task.
Step 4: Register the Queue and Worker
Register both services in Program.cs.
builder.Services.AddSingleton<BackgroundTaskQueue>();
builder.Services.AddHostedService<QueuedTaskWorker>();
The queue is registered as a singleton because the API and background worker must use the same queue instance.
If each service received a separate queue, tasks submitted by the API would not be visible to the worker.
Next, create an endpoint that queues a notification.
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/notifications")]
public class NotificationsController : ControllerBase
{
private readonly BackgroundTaskQueue _queue;
public NotificationsController(
BackgroundTaskQueue queue)
{
_queue = queue;
}
[HttpPost]
public async Task<IActionResult> SendNotification(
CancellationToken cancellationToken)
{
await _queue.QueueAsync(
async stoppingToken =>
{
await Task.Delay(
TimeSpan.FromSeconds(2),
stoppingToken);
Console.WriteLine(
"Notification processed.");
},
cancellationToken);
return Accepted(new
{
Message = "Notification queued."
});
}
}
The endpoint adds work to the queue and returns HTTP 202 Accepted after the queue accepts it.
The two-second delay is only a demonstration. In a real application, the queued task might call an email provider or generate a report.
Notice that the endpoint does not wait for the queued operation to finish. It waits only until the work is accepted by the in-memory queue.
However, this design has important limitations. The queue is not durable, so queued work can be lost if the process crashes or restarts. A production system that must reliably deliver notifications should consider a persistent job store or message broker.
Step 5: Handle Dependency Injection Correctly
Background tasks often need services such as a database context, repository, or email sender.
One important rule is that hosted services are typically registered as singletons, while services such as Entity Framework Core's DbContext are usually scoped.
Do not inject a scoped DbContext directly into a long-lived background worker.
Instead, create a dependency injection scope for each work item.
For example, inject IServiceScopeFactory into your worker and resolve the required services inside a scope:
using Microsoft.Extensions.DependencyInjection;
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider
.GetRequiredService<IOrderProcessingService>();
await service.ProcessPendingOrdersAsync(stoppingToken);
Here, _scopeFactory is an IServiceScopeFactory field injected through the worker's constructor.
This approach gives scoped services an appropriate lifetime for the operation. Dispose the scope when the work finishes so that its scoped dependencies are disposed correctly.
Best Practices for Production Applications
1. Use durable queues for important work
An in-memory queue is suitable for simple tasks where losing pending work is acceptable. For payments, order processing, or business-critical notifications, use durable storage or a message broker that supports recovery.
2. Handle failures explicitly
Logging an exception is useful, but critical tasks may need retries, dead-letter handling, and a way to inspect failed jobs.
Make retries safe. A task that sends an email or processes an order should not accidentally perform the same business operation multiple times.
3. Support cancellation and graceful shutdown
Pass cancellation tokens to database calls, HTTP requests, and other operations that support cancellation. During shutdown, give active tasks a reasonable opportunity to finish.
Cancellation does not guarantee that every operation can be stopped immediately. Design the task so that partial work can be handled safely.
4. Limit concurrency
Processing too many tasks at once can overwhelm a database or external service. Start with a controlled number of workers and increase concurrency only after measuring resource usage and throughput.
5. Monitor queue length and processing failures
Track pending work, processing duration, failed tasks, and retry counts. A queue that continues growing may indicate that incoming work exceeds the system's processing capacity.
Common Mistakes and Troubleshooting
Problem | Possible cause | Recommended action |
|---|
Background work never runs | Worker is not registered | Register it with AddHostedService() |
Queued tasks disappear after restart | Queue exists only in memory | Use durable storage for important tasks |
Database context errors occur | Scoped service is used incorrectly | Create a scope for each work item |
Worker stops after an exception | An exception escapes the worker loop | Handle failures around individual tasks |
API requests wait too long | Queue is full or processing is synchronous | Review queue capacity and backpressure |
Tasks run more than once | Retries or multiple consumers repeat an operation | Make processing idempotent |
A common mistake is assuming that returning 202 Accepted means the background task has completed successfully. It only indicates that the request was accepted for processing. If clients need to track progress, return a job identifier and provide an endpoint for checking its status.
Advantages and Disadvantages
Advantages
Keeps long-running work out of the immediate request-response flow.
Helps APIs respond sooner when work can be deferred.
Allows background processing to run independently of incoming requests.
Supports controlled task execution and centralized logging.
Disadvantages
In-memory tasks can be lost during application restarts.
Failures and retries require deliberate handling.
Background workers consume application resources.
Queue congestion can delay work.
Reliable processing often requires additional infrastructure.
Summary
ASP.NET Core provides BackgroundService for running hosted workers and Channel<T> for passing work between API requests and background consumers.
These tools are useful for tasks such as sending notifications, generating reports, and processing files without making clients wait for the entire operation.
For production applications, choose the approach based on how important the work is. An in-memory queue may be enough for noncritical tasks, while business-critical operations generally need durable storage, retry handling, monitoring, and protection against duplicate processing.
The main goal is to separate work that must happen before an API responds from work that can safely happen afterward, without sacrificing reliability.
Join the conversation! Your thoughts help the community grow.