A simple and scalable way to queue fire-and-forget background work in ASP.NET Core using dependency injection, scoped lifetimes, and category-based parallelism.
This service enables background work execution without requiring hosted services in your own code, while supporting graceful shutdown, cancellation, and error handling.
- Scoped dependency injection for background tasks
- Optional named categories with configurable concurrency and queueing
- Bounded capacity with task rejection
- Graceful cancellation on shutdown
- Customizable exception logging via
IOffloadWorkExceptionLogger - Agressive unit testing
Register the service with default configuration:
// Defaults to a MaxDegreeOfParallelism of 3, unbounded capacity
builder.Services.AddOffloadWorkService();Or customize the default configuration:
builder.Services.AddOffloadWorkService((OffloadWorkServiceOptions opt) =>
{
// Controls how many tasks can run concurrently
opt.MaxDegreeOfParallelism = 5;
// Limits the number of active tasks (-1 for unlimited)
opt.BoundedCapacity = 100;
});Or define multiple named categories:
builder.Services.AddOffloadWorkService((OffloadWorkServiceCategoryOptions cat) =>
{
cat.AddCategory("email", new OffloadWorkServiceOptions
{
MaxDegreeOfParallelism = 2,
BoundedCapacity = 10
});
cat.AddCategory("pdf", new OffloadWorkServiceOptions
{
MaxDegreeOfParallelism = 4,
BoundedCapacity = -1 // unbounded
});
});Inject IOffloadWorkService into your controller or service.
public sealed class MyController: ControllerBase
{
private readonly IOffloadWorkService _offloader;
public MyController(IOffloadWorkService offloadWorkService)
{
_offloader = offloadWorkService;
}
}Offload to the default category:
[HttpPost, Route("reindex")]
public IActionResult Reindex()
{
var accepted = _offloader.Offload(async (sp, _, ct) =>
{
var search = sp.GetRequiredService<ISearchIndexer>();
await search.ReindexAsync(ct);
}, state: default);
return accepted ? Accepted() :
StatusCode(StatusCodes.Status429TooManyRequests, "Queue full");
}Offload to a named category:
[HttpPost, Route("send-email")]
public IActionResult SendEmail([FromBody] EmailRequest request)
{
var accepted = _offloader.Offload("email", async (sp, _, ct) =>
{
var sender = sp.GetRequiredService<IEmailSender>();
await sender.SendAsync(request.To, request.Subject, request.Body, ct);
}, state: default);
return accepted ? Accepted() :
StatusCode(StatusCodes.Status429TooManyRequests, "Email queue full");
}Offload and avoid closures:
[HttpPost, Route("send-email")]
public IActionResult SendEmail([FromBody] EmailRequest request)
{
var state = (request.To, request.Subject, request.Body);
var accepted = _offloader.Offload("email", async static (sp, state, ct) =>
{
var sender = sp.GetRequiredService<IEmailSender>();
await sender.SendAsync(state.To, state.Subject, state.Body, ct);
}, state);
return accepted ? Accepted() :
StatusCode(StatusCodes.Status429TooManyRequests, "Email queue full");
}You can also retrieve the current length of the task queue for monitoring or logging purposes:
var queueLength = _offloader.GetActiveCount();
var queueLengthEmail = _offloader.GetActiveCount("email");Implement a custom logger to capture exceptions from background tasks.
public sealed class MyExceptionLogger : IOffloadWorkExceptionLogger
{
public void Log(Exception ex, string? category)
{
// Send to telemetry, logger, etc.
}
}Register it:
builder.Services.AddTransient<IOffloadWorkExceptionLogger, MyExceptionLogger>();If registered, it will be used in place of the default ILogger<OffloadWorkService> for exceptions.
BoundedCapacityis total active + queued items- Offload returns
falseif the queue is full - Background tasks receive a scoped
IServiceProvider - Cancellation tokens are honored during shutdown
- Logging is customizable but falls back to
ILoggerif needed
The service cancels all running and queued tasks during shutdown using linked cancellation tokens. Resources are disposed automatically.
Contributions are welcome! If you have improvements or bug fixes, please open an issue or submit a pull request.
This project is licensed under the MIT License.