Handling Long-Running Tasks in ASP.NET Core Using BackgroundService

Waleed NaveedWaleed Naveed
4 min read

In real-world ASP​‌.NET Core applications, certain operations — like importing large Excel files, generating reports, or syncing external systems — can take a significant amount of time.
Running these tasks directly inside API requests often leads to timeouts, poor user experience, and scalability issues.

So how do we offload these operations cleanly while still giving users feedback about what’s happening?

That’s exactly what this blog is about.

We’ll walk through how to handle long-running tasks in ASP​‌.NET Core using BackgroundService, while keeping the API responsive and exposing a status endpoint to monitor job progress.

The Problem

Let’s say we want to import users from a large Excel file. This isn’t a job that completes in a few milliseconds. Imagine validating thousands of rows, running business rules, and inserting records — it may take some time.

Running the task inside the controller means the API will hang — a bad practice. Instead, we’ll:

  • Offload the task to a background service

  • Return immediately from the API call

  • Provide a status endpoint to track job progress

What We'll Build

  • A POST endpoint to trigger a long-running background job

  • A BackgroundService to handle the import

  • A service to store job status

  • A GET endpoint to check the job status

  • Dummy Excel data via Mockaroo

  • Save the dummy excel file data in an Sqlite database (AppData.db)

Step 1 – Setup the Status Enum and Status Service

public enum BGSStatusEnum
{
    NotStarted,
    InProgress,
    Completed,
    Failed
}

public interface IBGSStatusService
{
    BGSStatusEnum BGSStatus { get; set; }
}

public class BGSStatusService : IBGSStatusService
{
    public BGSStatusEnum BGSStatus { get; set; } = BGSStatusEnum.NotStarted;
}

Step 2 – The Background Job Service (UserImportBGS)

We use an interface to trigger the background job safely:

public interface IUserImportTrigger
{
    void TriggerImport();
}

Actual service:

public class UserImportBGS : BackgroundService, IUserImportTrigger
{
    private readonly IServiceProvider _serviceProvider;
    private readonly IBGSStatusService _bGSStatusService;
    private bool _shouldRun;

    public UserImportBGS(IServiceProvider serviceProvider, IBGSStatusService bGSStatusService)
    {
        _serviceProvider = serviceProvider;
        _bGSStatusService = bGSStatusService;
    }

    public void TriggerImport()
    {
        _shouldRun = true;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            if (_shouldRun)
            {
                using var scope = _serviceProvider.CreateScope();
                var userImportService = scope.ServiceProvider.GetRequiredService<IUserImportService>();

                try
                {
                    _bGSStatusService.BGSStatus = BGSStatusEnum.InProgress;
                    await userImportService.ImportAsync(stoppingToken);
                    _bGSStatusService.BGSStatus = BGSStatusEnum.Completed;
                }
                catch (Exception)
                {
                    _bGSStatusService.BGSStatus = BGSStatusEnum.Failed;
                }

                _shouldRun = false;
            }

            await Task.Delay(1000, stoppingToken); // poll every second
        }
    }
}

Why We Need a Trigger Interface

By default, when we register AddHostedService<UserImportBGS>(), ASP​‌.NET Core only registers it as an IHostedService.

So this won’t work:

// ❌ Invalid
public UserImportController(UserImportBGS bgs) { ... }

The Fix

We register it as:

builder.Services.AddSingleton<IUserImportTrigger, UserImportBGS>();
builder.Services.AddHostedService(sp => (UserImportBGS)sp.GetRequiredService<IUserImportTrigger>());

Now we can safely inject it into the controller.

Step 3 – Controller to Trigger and Monitor Job

[Route("api/[controller]")]
[ApiController]
public class UserImportController : ControllerBase
{
    private readonly IUserImportTrigger _userImportTrigger;
    private readonly IBGSStatusService _bGSStatusService;

    public UserImportController(IUserImportTrigger userImportTrigger, IBGSStatusService bGSStatusService)
    {
        _userImportTrigger = userImportTrigger;
        _bGSStatusService = bGSStatusService;
    }

    [HttpPost]
    public IActionResult StartUserImport()
    {
        _userImportTrigger.TriggerImport();
        return Ok("User import is started in the background");
    }

    [HttpGet("status")]
    public IActionResult GetUserImportStatus()
    {
        return Ok(new { status = _bGSStatusService.BGSStatus.ToString() });
    }
}

Testing

We have placed an Excel file (MOCK_DATA.xlsx) with dummy users from Mockaroo in the Uploads folder. When POST /api/UserImport endpoint is called, this file will be processed in the background service.

In a real-world scenario, importing each row might involve time-consuming operations like validating data, transforming values, or calling external APIs.
To simulate that, we added a small delay using Task.Delay(200) after processing each row.

This gives a realistic experience of how long a typical import might take — and also allows us to properly test our background job and status endpoint behavior.

Step 1 – Trigger the job

Execute POST /api/UserImport API from swagger:

API has responded and the background service has started

Step 2 – Monitor Progress: InProgress Status

Let’s execute the GET /api/UserImport/status API from swagger:

It shows the background job is in-progress

Step 3 – Final Check: Completed Status

Let’s again execute the GET /api/UserImport/status API from swagger:

Now the status is Completed, which shows that background service has done its processing.

Step 4 – Check SQLite DB for imported data

Where Did the Excel File Come From?

We used Mockaroo to generate a dummy Excel file with 1000 fake user rows — Id, FirstName, LastName, Email, Gender, IP Address.

It’s a great free tool to simulate realistic data for testing imports.

Developer Notes

  • If SQLite database isn’t found, it’s created automatically

  • No Entity Framework — raw SQL only

  • Excel file is read from /Uploads folder (can be changed)

  • Import delay is simulated via Task.Delay(200)

Final Thoughts

This pattern is perfect for offloading slow jobs in ASP​‌.NET Core. You get:

  • Zero timeouts

  • Smooth API experience

  • Status endpoint

Source Code

Source code is available on GitHub

0
Subscribe to my newsletter

Read articles from Waleed Naveed directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Waleed Naveed
Waleed Naveed