DEV Community

Cover image for ASP.NET Core Background Services: How to Run Reliable Background Tasks in .NET
ToolBench
ToolBench

Posted on

ASP.NET Core Background Services: How to Run Reliable Background Tasks in .NET

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The important method is:

ExecuteAsync()
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

The worker has no way to respond to application shutdown.

Instead:

while (!stoppingToken.IsCancellationRequested)
{
    await DoWorkAsync(stoppingToken);
}
Enter fullscreen mode Exit fullscreen mode

Now ASP.NET Core can tell the worker:

"The application is shutting down."
Enter fullscreen mode Exit fullscreen mode

The worker can stop gracefully.


5. Don't Block the Thread

Avoid blocking calls such as:

Thread.Sleep(5000);
Enter fullscreen mode Exit fullscreen mode

inside asynchronous background work.

Prefer:

await Task.Delay(
    TimeSpan.FromSeconds(5),
    stoppingToken);
Enter fullscreen mode Exit fullscreen mode

This allows the thread to be used efficiently while waiting.

The same principle applies to database and HTTP operations.

Prefer:

await repository.GetDataAsync(
    stoppingToken);
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A simple in-memory queue can use:

Channel<T>
Enter fullscreen mode Exit fullscreen mode

For example:

public interface IBackgroundTaskQueue
{
    ValueTask QueueAsync(
        Func<CancellationToken, ValueTask> workItem);

    ValueTask<Func<CancellationToken, ValueTask>>
        DequeueAsync(
            CancellationToken cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.");
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

https://toolbenchapp.com/

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)

Collapse
 
iqtechsolutions profile image
Ivan Rossouw

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 before MarkAsProcessedAsync causes a replay. For database-backed work, I’d claim the row atomically—using UPDATE ... WHERE Status = 'Pending' OUTPUT INSERTED.Id or 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.