In this article, we’ll explore different ways of running background tasks in ASP.NET Core applications without depending on external providers.

To download the source code for this article, you can visit our GitHub repository.

Let’s jump in!

Example Setup

For this article, we will use a minimal API with an in-memory database project for simplicity. Our code revolves around a database that deals with clients:

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!
public class Client
{
    public required Guid Id { get; set; }
    public required string Name { get; set; }
    public DateOnly FirstOrderDate { get; set; }
    public DateOnly LastOrderDate { get; set; }
    public bool IsActive { get; set; }
}

To interact with our database we’ll use two endpoints:

app.MapGet("/clients/active", (ApplicationDbContext context) =>
{
    return context.Clients.Where(x => x.IsActive).AsNoTracking();
})
.WithName("GetActiveClients")
.WithOpenApi();

app.MapGet("/clients/archived", (ApplicationDbContext context) =>
{
    return context.Clients.Where(x => !x.IsActive).AsNoTracking();
})
.WithName("GetArchivedClients")
.WithOpenApi();

There are two endpoints – one to retrieve active clients and one for inactive clients. 

We also have a Worker class:

public class Worker(TimeProvider timeProvider) : IWorker
{
    private static readonly string[] Names =
        ["John", "Jane", "Alice", "Bob", "Charlie", "David", "Eva", "Frank"];
}

We create a Worker class that uses a primary constructor and takes a parameter of TimeProvider type. Another thing we do here is to declare a static array of names that we will use later to seed the database. 

After that, we register the time provider:

builder.Services.AddSingleton(TimeProvider.System);

Our Worker class has two methods that we will use in our background services:

public async Task<int> ArchiveOldClientsAsync(ApplicationDbContext context,
    CancellationToken cancellationToken = default)
{
    var cutoff = timeProvider.GetUtcNow().AddMonths(-6);

    var clientsToBeArchived = context.Clients
        .Where(x => x.LastOrderDate <= cutoff)
        .AsAsyncEnumerable()
        .WithCancellation(cancellationToken);

    await foreach (var client in clientsToBeArchived)
    {
        client.IsActive = false;
    }

    return await context.SaveChangesAsync(cancellationToken);
}

The ArchiveOldClientsAsync() method is responsible for archiving clients that have not ordered in the last six months.

The other method is:

public async Task<int> SeedDatabaseAsync(ApplicationDbContext context,
    CancellationToken cancellationToken = default)
{
    await context.Database.EnsureCreatedAsync(cancellationToken);

    for (int i = 0; i < 10; i++)
    {
        context.Clients.Add(new()
        {
            Id = Guid.NewGuid(),
            Name = Names[Random.Shared.Next(Names.Length)],
            FirstOrderDate = GenerateRandomDate(),
            LastOrderDate = GenerateRandomDate(),
            IsActive = true,
        });
    }

    return await context.SaveChangesAsync(cancellationToken);
}

The SeedDatabaseAsync() method ensures that the database is created and seeds it with ten random clients using the static Names property we declared before.

Running One-off Background Tasks in ASP.NET Core

Let’s explore different ways of creating a service that will run our database migrations at the start of our application.

Running One-off Background Tasks With IHostedService in ASP.NET Core

The first way we can have a one-off background service is by implementing the IHostedService interface. The interface has two methods – StartAsync() and StopAsync(). The first one runs once when our application starts; meanwhile, the second one runs when it stops.

Next, we create a new class and implement the interface:

public class InitializationHostedService(
    IWorker worker,
    IServiceProvider serviceProvider) : IHostedService
{
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        using IServiceScope scope = serviceProvider.CreateScope();

        await using var context = scope.ServiceProvider
            .GetRequiredService<ApplicationDbContext>();

        await worker.SeedDatabaseAsync(context, cancellationToken);
    }

    public Task StopAsync(CancellationToken cancellationToken)
        => Task.CompletedTask;
}

First, we create the InitializationHostedService class and implement the IHostedService interface. Using a primary constructor, we inject an IWorker and IServiceProvider instances.

In the StartAsync() method, we use the IServiceProvider to get access to our ApplicationDbContext class and pass it to the SeedDatabaseAsync() method. We cannot inject our DbContext directly as it’s registered as a scoped service, and we can only inject transient or singleton services when implementing the IHostedService interface.

In the StopAsync() method, we just return Task.CompletedTask.

The final step to perform is to register our service:

builder.Services.AddHostedService<InitializationHostedService>();

In our Program class, we use the AddHostedService() method on our IServiceCollection instance to register the service we just created.

Running One-off Background Tasks With BackgroundService 

Another option for background tasks is to implement a class deriving from the BackgroundService abstract class, implementing the ExecuteAsync() method:

public class InitializationBackgroundService(
    IWorker worker,
    IServiceProvider serviceProvider) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using IServiceScope scope = serviceProvider.CreateScope();

        await using var context = scope.ServiceProvider
            .GetRequiredService<ApplicationDbContext>();

        await worker.SeedDatabaseAsync(context, stoppingToken);
    }
}

We create InitializationBackgroundService, deriving from BackgroundService. As with the IHostedService interface, we cannot use a scoped service directly, so we inject an IServiceProvider instance.

Inside the ExecuteAsync() method, we again get a hold of our ApplicationDbContext class and call the SeedDatabaseAsync() method.

Then, we register our service:

builder.Services.AddHostedService<InitializationBackgroundService>();

We achieve this by using the same AddHostedService() method in our Program class.

Running One-off Background Tasks With IHostedLifecycleService in ASP.NET Core

With .NET 8 we got a brand new interface for doing background work. The new IHostedLifecycleService interface implements the already familiar IHostedService interface. However, with four additional methods, the interface provides us more control over when to run something in the background of our application.

First in line is the StartingAsync() method that executes while our application is starting, before the StartAsync() method. The StartedAsync() method is triggered after our application has been started. The StoppingAsync() method runs once we start shutting down our application. Finally, the StoppedAsync() is executed once our application stops, and after the StopAsync() method.

Let’s implement the interface:

public class InitializationHostedLifecycleService(
    IWorker worker,
    IServiceProvider serviceProvider) : IHostedLifecycleService
{
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        using IServiceScope scope = serviceProvider.CreateScope();

        await using var context = scope.ServiceProvider
            .GetRequiredService<ApplicationDbContext>();

        await worker.SeedDatabaseAsync(context, cancellationToken);
    }

    // Other methods removed for brevity
}

We create the InitializationHostedLifecycleService class and implement the StartAsync() method. Inside it, we have our already familiar code that utilizes the SeedDatabaseAsync() method. For all other methods, we just return a Task.CompletedTask. Registering is done via the already familiar way, using the AddHostedService() method.

Running Recurring Background Tasks in ASP.NET Core

Let’s imagine we need to set the IsActive property of a Client entity to false if their LastOrderDate is more than six months ago. We can run a recurring background task in .NET, without relying on external providers such as Hangfire or Quartz. 

Before we start, we create an interface:

public interface IPeriodicTimer : IDisposable
{
    ValueTask<bool> WaitForNextTickAsync(CancellationToken cancellationToken = default);
}

We create the IPeriodicTimer interface and declare the WaitForNextTickAsync() method. With it, we abstract the built-in PeriodicTimer class. As a result, this will help us both with DI and testability.

Next, we implement the interface:

public sealed class DailyPeriodicTimer : IPeriodicTimer
{
    private readonly PeriodicTimer _timer = new(TimeSpan.FromHours(24));

    public async ValueTask<bool> WaitForNextTickAsync(CancellationToken cancellationToken = default)
        => await _timer.WaitForNextTickAsync(cancellationToken);

    public void Dispose() => _timer.Dispose();
}

We create the DailyPeriodicTimer class to wrap the functionality of the built-in PeriodicTimer class. In addition, we set the timer to tick once every 24 hours.

Running Recurring Tasks With IHostedService in ASP.NET Core

Let’s see how we can utilize the IHostedService interface to run a recurring task:

public class PeriodicHostedService(
    IWorker worker,
    IPeriodicTimer timer,
    IServiceProvider serviceProvider) : IHostedService
{
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        using IServiceScope scope = serviceProvider.CreateScope();

        await using var context = scope.ServiceProvider
            .GetRequiredService<ApplicationDbContext>();

        while (!cancellationToken.IsCancellationRequested &&
            await timer.WaitForNextTickAsync(cancellationToken))
        {
            await worker.ArchiveOldClientsAsync(context, cancellationToken);
        }
    }

    public Task StopAsync(CancellationToken cancellationToken)
        => Task.CompletedTask;
}

The first thing we do is to create the PeriodicHostedService class and implement the IHostedService interface. This time we inject an IPeriodicTimer instance alongside the IWorker and IServiceProvider instances. Next, inside the StartAsync() method, we get an instance of our ApplicationDbContext via the already familiar approach.

Next, we have a while loop that will run every 24 hours until cancelation is requested or the WaitForNextTickAsync() returns false. Inside the loop, we call the worker’s ArchiveOldClientsAsync() method and pass our database context instance to it.

The StopAsync() method just returns a Task.CompletedTask.

There are several things we need to do for this to work:

builder.Services.AddTransient<IPeriodicTimer, DailyPeriodicTimer>();

builder.Services.Configure<HostOptions>(options =>
{
    options.ServicesStartConcurrently = true;
});

builder.Services.AddHostedService<PeriodicHostedService>();

First, we register the DailyPeriodicTimer as a transient service in our DI container.

Next, we configure the HostOptions class by setting ServicesStartConcurrently to true. We do this, to ensure that every service that implements the IHostedService interface will start concurrently. Otherwise, our services will start one by one in sequential order, consequently blocking our application from starting. This is far from ideal as our PeriodicHostedService class will virtually block the application forever.

Finally,  we register the PeriodicHostedService class using the AddHostedService() extension method.

Running Recurring Tasks With BackgroundService in ASP.NET Core

It’s no surprise that we can use the BackgroundService abstract class to achieve the same:

public class PeriodicBackgroundService(
    IWorker worker,
    IPeriodicTimer timer,
    IServiceProvider serviceProvider) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using IServiceScope scope = serviceProvider.CreateScope();

        await using var context = scope.ServiceProvider
            .GetRequiredService<ApplicationDbContext>();

        while (!stoppingToken.IsCancellationRequested &&
            await timer.WaitForNextTickAsync(stoppingToken))
        {
            await worker.ArchiveOldClientsAsync(context, stoppingToken);
        }
    }
}

We create the PeriodicBackgroundService and make it implement the BackgroundService class. We have the same logic as with our PeriodicHostedService class, but here it is located inside the ExecuteAsync() method.

Registration for the class is mandatory again:

builder.Services.AddHostedService<PeriodicBackgroundService>();

Using the same AddHostedService() extension method, we add the PeriodicBackgroundService to the DI container and our periodic service will kick off automatically when our application starts.

Running Recurring Tasks With IHostedLifecycleService

Achieving the same recurring tasks when implementing the IHostedLifecycleService interface is a breeze:

public class PeriodicHostedLifecycleService(
    IWorker worker,
    IPeriodicTimer timer,
    IServiceProvider serviceProvider) : IHostedLifecycleService
{
    public async Task StartedAsync(CancellationToken cancellationToken)
    {
        using IServiceScope scope = serviceProvider.CreateScope();

        await using var context = scope.ServiceProvider
            .GetRequiredService<ApplicationDbContext>();

        while (!cancellationToken.IsCancellationRequested &&
            await timer.WaitForNextTickAsync(cancellationToken))
        {
            await worker.ArchiveOldClientsAsync(context, cancellationToken);
        }
    }

    // Other methods removed for brevity
}

In our custom PeriodicHostedLifecycleService class, we place the same logic in the StartedAsync() method. The IHostedLifecycleService interface gives us more granular control over when our background tasks start and run.

By using the StartedAsync() method, our logic will run after our application’s start. This way we won’t block its start indefinitely. We don’t have to set the ServicesStartConcurrently property to true if we don’t have other background jobs that need to be run concurrently when the application starts. We make all other methods to return a Task.CompletedTask as we don’t rely on them in our implementation.

Registration to the DI container is again done via the AddHostedService() extension method.

Conclusion

In this article, we delved into different ways of executing background tasks in .NET applications. We’ve explored versatile approaches, from initializing one-off tasks using Hosted Service and Background Service to recurring tasks with periodic intervals.

Whether opting for the simplicity of Hosted Service, the abstract class convenience of Background Service, or the enhanced control of Hosted Lifecycle Service, we can seamlessly integrate background processes, ensuring efficient task execution without external dependencies. Now equipped with diverse tools, we can tailor solutions to specific application requirements, fostering a robust and self-contained development environment.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!