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


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 importA 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
Subscribe to my newsletter
Read articles from Waleed Naveed directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
