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.
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 task | Why it should run in the background |
|---|---|
| Sending a welcome email | The user should not wait for a slow mail server. |
| Resizing an uploaded photo | Heavy work; let the upload finish fast, resize later. |
| Cleaning old log files at night | Nobody triggers it; it just needs to happen on a timer. |
| Checking a message queue | A loop that waits for new messages and handles them. |
| Refreshing a cache every 5 minutes | Repeating 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
Steps
User request
User asks for something
Quick reply
App answers fast
Background worker
Picks up slow work
Done later
Finishes without blocking the user
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.
IHostedServiceis the basic interface. It has two methods:StartAsync(called at startup) andStopAsync(called at shutdown). You get full control, but you handle every detail yourself.BackgroundServiceis a helper base class that already implementsIHostedServicefor 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.
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:
PeriodicTimeris the modern timer in .NET.WaitForNextTickAsyncwaits for the next tick without blocking a thread, which is gentle on your server.stoppingTokenis aCancellationToken. When the app is shutting down, this token is "cancelled." Passing it intoWaitForNextTickAsyncmeans 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.
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 firstawaitbefore 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
Steps
Singleton service
Lives for the whole app
Create scope
CreateAsyncScope per pass
Resolve DbContext
Fresh, short-lived
Do work
Query or update
Dispose scope
Connection returns to pool
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.
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 timer | BackgroundService with PeriodicTimer |
| One-off jobs handed off from a request | Channel<T> queue plus a worker |
| Jobs that survive app restarts | Hangfire or Quartz.NET (store in a database) |
| Scheduled jobs with retries and a dashboard | Hangfire or Quartz.NET |
| Reacting to messages from another system | A 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
IServiceScopeFactoryand create a scope per unit of work, as shown above. - Letting exceptions escape. An unhandled error in
ExecuteAsynccan 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
BackgroundServiceand overrideExecuteAsyncfor most cases; register it withAddHostedService<T>(). - Use
PeriodicTimerfor repeating work, and always respect thestoppingTokenso shutdown is graceful. - To use a
DbContextor other scoped service, injectIServiceScopeFactoryand 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
- Background tasks with hosted services in ASP.NET Core — Microsoft Learn
- Create a Queue Service — Microsoft Learn
- Implement background tasks with IHostedService and BackgroundService — Microsoft Learn
Related Posts
Building Async APIs in ASP.NET Core the Right Way
Learn to build fast, safe async APIs in ASP.NET Core: async/await, CancellationToken, avoiding .Result deadlocks, and thread pool tips.
Adding Real-Time Functionality to .NET Apps with SignalR
Learn ASP.NET Core SignalR step by step: hubs, clients, groups, and scaling with Redis or Azure, explained for absolute beginners.
Scheduling Background Jobs with Quartz.NET: Advanced Concepts
Go deeper with Quartz.NET in .NET 10: persistent job stores, clustering, misfire handling, cron triggers, calendars, and safe retries for real production jobs.
Top 15 Mistakes Developers Make When Creating Web APIs
A warm, beginner-friendly tour of the 15 most common Web API mistakes in ASP.NET Core, with simple fixes, diagrams, tables, and clear C# examples.
How to Increase the Performance of Web APIs in .NET
A friendly, step-by-step guide to making your ASP.NET Core Web APIs fast: async, caching, query tuning, compression, and pooling in .NET 10.
Authentication and Authorization Best Practices in ASP.NET Core
A friendly guide to authentication and authorization in ASP.NET Core for .NET 10 — JWT, cookies, claims, roles, policies, and security best practices with diagrams.