Skip to main content
SEMastery
ASP.NETbeginner

Running Background Tasks in ASP.NET Core: A Beginner's Guide

Learn background tasks in ASP.NET Core with simple examples: IHostedService, BackgroundService, timers, scoped services, and a queue using Channels, with clear diagrams.

12 min readUpdated January 18, 2026

The night-shift worker in a sweet shop

Picture a busy sweet shop in your town. During the day, the man at the counter serves customers. Someone asks for a box of laddoos, he weighs it, takes the money, and hands it over. The customer waits only for their own order. That is like a normal web request: the user asks, the server answers, the user leaves.

But the shop also has work that nobody at the counter should wait for. The floor must be swept. Tomorrow's milk must be ordered. The big pot of syrup must be stirred every ten minutes so it does not burn. If the counter man stopped serving customers to stir the syrup, the queue would grow and everyone would be annoyed.

So the owner hires a night-shift worker. This worker does not face customers. They quietly do the slow, repeating jobs in the back: stir the syrup, sweep the floor, order the milk. The shop runs smoothly because the right work happens in the right place.

A background task in ASP.NET Core is exactly this night-shift worker. It runs inside your app, but away from the customer-facing requests. The user never waits for it. In this guide you will learn how to hire and manage these workers using the tools .NET gives you.

What counts as a background task?

Some work does not belong in the request and response cycle. Here are common examples.

Example taskWhy it should run in the background
Sending a welcome emailThe user should not wait for a slow mail server.
Resizing an uploaded photoHeavy work; let the upload finish fast, resize later.
Cleaning old log files at nightNobody triggers it; it just needs to happen on a timer.
Checking a message queueA loop that waits for new messages and handles them.
Refreshing a cache every 5 minutesRepeating work on a schedule.

The common idea is simple: the user does not need to stand and wait for this work, so move it off the request path.

Request path vs background path

User request
Quick reply
Background worker
Done later

Steps

1

User request

User asks for something

2

Quick reply

App answers fast

3

Background worker

Picks up slow work

4

Done later

Finishes without blocking the user

The user only waits for the fast part. Slow work is handed to a background worker.

Meet IHostedService and BackgroundService

ASP.NET Core has a built-in idea called a hosted service. A hosted service is a class that the app's host starts when the app starts, and stops when the app shuts down. Think of the host as the shop owner who tells the night-shift worker, "Start your shift now," and later, "Time to go home."

There are two ways to write one.

  1. IHostedService is the basic interface. It has two methods: StartAsync (called at startup) and StopAsync (called at shutdown). You get full control, but you handle every detail yourself.
  2. BackgroundService is a helper base class that already implements IHostedService for you. You override just one method, ExecuteAsync, and put your long-running loop there. This is what most people use.

Here is how the two relate.

Figure 1: BackgroundService is a ready-made class built on top of the IHostedService interface. You usually inherit from BackgroundService.

For almost every beginner case, the advice is the same: inherit from BackgroundService. You write less code, and the base class takes care of the boring wiring.

Your first background service: a timer worker

Let us build the syrup-stirrer. We want a worker that does a small job every few seconds. Create a class that inherits from BackgroundService and override ExecuteAsync.

public sealed class SyrupStirrer : BackgroundService
{
    private readonly ILogger<SyrupStirrer> _logger;
 
    public SyrupStirrer(ILogger<SyrupStirrer> logger)
    {
        _logger = logger;
    }
 
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // PeriodicTimer is the modern, clean way to repeat work.
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
 
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            _logger.LogInformation("Stirring the syrup at {Time}", DateTimeOffset.Now);
            // ... do the actual work here ...
        }
    }
}

Two small but important details:

  • PeriodicTimer is the modern timer in .NET. WaitForNextTickAsync waits for the next tick without blocking a thread, which is gentle on your server.
  • stoppingToken is a CancellationToken. When the app is shutting down, this token is "cancelled." Passing it into WaitForNextTickAsync means the loop ends cleanly instead of hanging. Always respect this token.

Now register the service in Program.cs so the host knows about it.

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddHostedService<SyrupStirrer>();
 
var app = builder.Build();
app.Run();

That single line, AddHostedService<SyrupStirrer>(), hires the worker. When the app starts, the worker starts. When the app stops, the worker is asked to stop. You did not have to manage any threads yourself.

The lifecycle: start, run, stop

It helps to picture the life of a background service from the moment the app boots to the moment it shuts down.

Figure 2: The lifecycle of a hosted service, from app start to graceful shutdown.

When shutdown begins, the host cancels the stopping token and then waits in StopAsync for your ExecuteAsync to finish. This is why your loop must watch the token and exit quickly. If your loop ignores the token and keeps running, the app cannot shut down nicely. A good worker is like a polite employee: when told the shift is over, they finish the current small step and leave.

Tip: Do not put slow, blocking setup work at the very top of ExecuteAsync. The host waits for the first await before it considers startup complete, so heavy work there can delay your whole app from starting.

Using a database safely with scopes

Here is a trap that catches many beginners. A BackgroundService is created once and lives for the whole life of the app. In dependency injection terms, it is a singleton. But things like Entity Framework's DbContext are scoped — they are meant to live for a short unit of work and then be thrown away.

You cannot inject a scoped DbContext straight into a singleton. If you try, .NET will throw an error, or worse, you will share one DbContext across many operations and get strange bugs.

The fix is to inject IServiceScopeFactory and create a fresh scope for each unit of work.

public sealed class OrderCleanupService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OrderCleanupService> _logger;
 
    public OrderCleanupService(
        IServiceScopeFactory scopeFactory,
        ILogger<OrderCleanupService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }
 
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30));
 
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                // Make a new scope just for this run.
                await using var scope = _scopeFactory.CreateAsyncScope();
 
                var db = scope.ServiceProvider
                    .GetRequiredService<AppDbContext>();
 
                var oldCount = await db.Orders
                    .Where(o => o.CreatedAt < DateTime.UtcNow.AddYears(-1))
                    .ExecuteDeleteAsync(stoppingToken);
 
                _logger.LogInformation("Removed {Count} old orders", oldCount);
            }
            catch (Exception ex)
            {
                // One bad run must not kill the whole worker.
                _logger.LogError(ex, "Order cleanup failed");
            }
        }
    }
}

Notice two safety habits here. First, the scope is created inside the loop and disposed at the end of each pass, which returns the database connection to the pool. Second, the work is wrapped in a try and catch block. If one run fails, we log it and keep looping. Without that, an unhandled error could stop the whole service.

Scoped work inside a singleton service

Singleton service
Create scope
Resolve DbContext
Do work
Dispose scope

Steps

1

Singleton service

Lives for the whole app

2

Create scope

CreateAsyncScope per pass

3

Resolve DbContext

Fresh, short-lived

4

Do work

Query or update

5

Dispose scope

Connection returns to pool

Each pass of the loop gets its own short-lived scope for the database.

Queued background tasks with Channels

A timer is great for repeating work. But sometimes a web request wants to hand off a one-time job. For example, a user uploads a photo, and you want to resize it later without making them wait. You need a queue: the request drops a job in, and a background worker picks it up.

The modern way to build this in .NET is with System.Threading.Channels. A Channel<T> is a thread-safe, async-friendly pipe. One side writes items in (the producer, your web request), the other side reads items out (the consumer, your background worker). It is built for exactly this producer and consumer pattern, and it works smoothly with async and await.

Figure 3: A request writes a job into the channel; a background worker reads jobs out and runs them one by one.

Let us build it in three small parts: the queue interface, the worker that drains the queue, and the wiring.

First, the queue itself. It wraps a bounded channel so it cannot grow forever.

public interface IBackgroundTaskQueue
{
    ValueTask EnqueueAsync(Func<CancellationToken, ValueTask> workItem);
    ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(
        CancellationToken cancellationToken);
}
 
public sealed class BackgroundTaskQueue : IBackgroundTaskQueue
{
    private readonly Channel<Func<CancellationToken, ValueTask>> _queue;
 
    public BackgroundTaskQueue(int capacity)
    {
        // Bounded channel: if it fills up, writers wait (backpressure).
        var options = new BoundedChannelOptions(capacity)
        {
            FullMode = BoundedChannelFullMode.Wait
        };
        _queue = Channel.CreateBounded<Func<CancellationToken, ValueTask>>(options);
    }
 
    public async ValueTask EnqueueAsync(
        Func<CancellationToken, ValueTask> workItem)
    {
        ArgumentNullException.ThrowIfNull(workItem);
        await _queue.Writer.WriteAsync(workItem);
    }
 
    public async ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(
        CancellationToken cancellationToken)
    {
        return await _queue.Reader.ReadAsync(cancellationToken);
    }
}

Next, the worker that drains the queue and runs each job. It loops forever, pulling one job at a time.

public sealed class QueuedHostedService : BackgroundService
{
    private readonly IBackgroundTaskQueue _queue;
    private readonly ILogger<QueuedHostedService> _logger;
 
    public QueuedHostedService(
        IBackgroundTaskQueue queue,
        ILogger<QueuedHostedService> logger)
    {
        _queue = queue;
        _logger = logger;
    }
 
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var workItem = await _queue.DequeueAsync(stoppingToken);
 
            try
            {
                await workItem(stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error running a queued job");
            }
        }
    }
}

Finally, the wiring in Program.cs, and an example of a request adding a job to the queue.

builder.Services.AddSingleton<IBackgroundTaskQueue>(
    _ => new BackgroundTaskQueue(capacity: 100));
builder.Services.AddHostedService<QueuedHostedService>();
 
// ... later, inside a minimal API endpoint ...
app.MapPost("/resize", async (IBackgroundTaskQueue queue) =>
{
    await queue.EnqueueAsync(async token =>
    {
        // This runs in the background, not during the request.
        await Task.Delay(2000, token); // pretend this is image resizing
    });
 
    return Results.Accepted();
});

The endpoint returns Accepted almost instantly. The real work happens later in the worker. The user is not kept waiting. Because we used a bounded channel with FullMode = Wait, if too many jobs pile up, new writers gently wait for space instead of using endless memory. That waiting is called backpressure, and it protects your server.

Choosing the right tool

Not every job belongs in a simple hosted service. Here is a quick guide.

You need...Good choice
Repeating work on a timerBackgroundService with PeriodicTimer
One-off jobs handed off from a requestChannel<T> queue plus a worker
Jobs that survive app restartsHangfire or Quartz.NET (store in a database)
Scheduled jobs with retries and a dashboardHangfire or Quartz.NET
Reacting to messages from another systemA worker reading from a real message broker

A few honest notes. Built-in hosted services are in-process: if your app restarts, any jobs still in memory are lost. That is fine for things you can safely redo, but not for jobs you must never lose. For durable, scheduled, or retryable jobs, reach for a dedicated library. Also, some well-known libraries changed their terms: MediatR and MassTransit are now commercially licensed, so check the license and pricing before adding them to a company project. Hangfire and Quartz.NET remain popular open options for durable background jobs.

Common mistakes to avoid

  • Forgetting the cancellation token. If your loop ignores stoppingToken, your app cannot shut down cleanly. Always pass it into awaits and check it.
  • Injecting a scoped service into a singleton. Use IServiceScopeFactory and create a scope per unit of work, as shown above.
  • Letting exceptions escape. An unhandled error in ExecuteAsync can stop the host. Wrap your work in try and catch, log it, and keep going.
  • Heavy work before the first await. Do not block startup with slow setup at the top of ExecuteAsync.
  • Treating in-memory queues as durable. They are not. If losing a job is unacceptable, use a database-backed or broker-backed queue.

Quick recap

  • A background task is work that runs inside your app but away from the user's request, like a night-shift worker in a shop.
  • Inherit from BackgroundService and override ExecuteAsync for most cases; register it with AddHostedService<T>().
  • Use PeriodicTimer for repeating work, and always respect the stoppingToken so shutdown is graceful.
  • To use a DbContext or other scoped service, inject IServiceScopeFactory and create a fresh scope per unit of work.
  • For one-off jobs handed off from a request, use a Channel<T> queue with a worker, and prefer a bounded channel for backpressure.
  • Wrap loop work in try and catch so one failure does not kill the whole service.
  • Built-in hosted services are in-process and not durable. For durable, scheduled, or retryable jobs, use Hangfire or Quartz.NET. Remember that MediatR and MassTransit are now commercially licensed.

References and further reading

Related Posts