A BackgroundService is a hosted service that executes work in the background within a .NET application.
When a user registers, we might need to do three things:
- Save the user to the database (10ms).
- Send a welcome email (1s - 3s).
- Resize their profile picture (500ms).
If you do all of this sequentially (await), the user will be staring at a loading screen for almost 4 seconds. In internet times, that’s an eternity.
The ideal approach would be:
Save to DB.
Respond to the user: “200 OK - Welcome!”.
In the background: send the email and process the photo.
In ASP.NET Core, this is done using Hosted Services. They are small processes that run alongside your Web API, within the same process.
We’ll implement them using the producer-consumer pattern with Channels.
The basic concept: BackgroundService
.NET provides us with a base class called BackgroundService. Any class that inherits from it and is registered in the system will start when the API starts and stop when the API stops.
It’s perfect for scheduled tasks (e.g., “Delete old logs every hour”).
using Microsoft.Extensions.Hosting;
public class CleanerService : BackgroundService
{
private readonly ILogger<CleanerService> _logger;
public CleanerService(ILogger<CleanerService> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Cleaning temporary files...");
// Simulate work
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
}To activate it, we add it in Program.cs:
builder.Services.AddHostedService<CleanerService>();Communication between the API and the service
The previous example is an infinite loop. But how do we tell the service: “Hey, send an email to THIS user now” from a Controller?
It’s not advisable to inject the BackgroundService directly into the controller. We need an intermediary, a mailbox where the controller leaves messages and the service picks them up.
That mailbox is a Queue. In .NET, we can create this queue in memory using System.Threading.Channels.
Implementing the queue
Let’s create a class that acts as a queue. It will be a Singleton so that the same instance is used for the entire application.
using System.Threading.Channels;
public interface IBackgroundTaskQueue
{
// Method for the Controller to enqueue tasks (Producer)
ValueTask QueueBackgroundWorkItemAsync(
Func<CancellationToken, ValueTask> workItem,
CancellationToken cancellationToken = default);
// Method for the Service to dequeue tasks (Consumer)
ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken);
}
public class BackgroundTaskQueue : IBackgroundTaskQueue
{
// Channel is an optimized thread-safe collection
private readonly Channel<Func<CancellationToken, ValueTask>> _queue;
public BackgroundTaskQueue(int capacity)
{
// Bounded: If the queue is full, the producer waits. Prevents RAM from exploding.
var options = new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait
};
_queue = Channel.CreateBounded<Func<CancellationToken, ValueTask>>(options);
}
public async ValueTask QueueBackgroundWorkItemAsync(
Func<CancellationToken, ValueTask> workItem,
CancellationToken cancellationToken = default)
{
await _queue.Writer.WriteAsync(workItem, cancellationToken);
}
public async ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
{
return await _queue.Reader.ReadAsync(cancellationToken);
}
}The worker processing the queue
Now we create the BackgroundService that waits for new items and executes them.
public class QueuedHostedService : BackgroundService
{
private readonly IBackgroundTaskQueue _taskQueue;
private readonly ILogger<QueuedHostedService> _logger;
public QueuedHostedService(IBackgroundTaskQueue taskQueue, ILogger<QueuedHostedService> logger)
{
_taskQueue = taskQueue;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Queue Service Started");
while (!stoppingToken.IsCancellationRequested)
{
// Waits here until there is something in the queue
var workItem = await _taskQueue.DequeueAsync(stoppingToken);
try
{
// Execute the task
await workItem(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing the background task");
}
}
}
}Register everything in Program.cs
// 1. The queue must be a Singleton (Single mailbox)
builder.Services.AddSingleton<IBackgroundTaskQueue>(ctx =>
{
return new BackgroundTaskQueue(100); // Capacity of 100 tasks
});
// 2. The processing service (Worker)
builder.Services.AddHostedService<QueuedHostedService>();Usage from the controller
Everything is ready! Now, when a user registers, we queue the email sending and return an immediate response.
[HttpPost("register")]
public async Task<IActionResult> RegisterUser(
UserDto dto,
CancellationToken requestAborted)
{
// 1. Save to DB (Fast)
_repo.Save(dto);
// 2. Queue heavy task (Email)
// Inject IBackgroundTaskQueue in the constructor
await _queue.QueueBackgroundWorkItemAsync(
async token =>
{
// Simulate slow email sending
await Task.Delay(2000, token);
Console.WriteLine($"Welcome email sent to {dto.Email}");
},
requestAborted);
// 3. Respond to the user NOW
return Accepted(); // 202 Accepted (The request was accepted for processing)
}Do not capture Scoped services from the request inside a background task. If the task needs a DbContext or another scoped service, create a scope inside the worker using IServiceScopeFactory.
When to use this vs a persistent queue
What we have done is an In-Memory solution.
- Advantage: It’s free, fast, and requires no extra infrastructure.
- Disadvantage: If you restart the server (or the API crashes), the tasks that were in the queue are lost.
Use this solution for:
- Sending non-critical emails.
- Processing images.
- Simple maintenance tasks.
Use Hangfire or RabbitMQ (Persistent Queues) for:
- Credit card payments.
- Critical processes that CANNOT be lost under any circumstances.
- Tasks that must automatically retry if they fail 5 times.