Move Slow Work Out of Your .NET API with BackgroundService and Channels
Offload slow side work from ASP.NET Core endpoints using a bounded System.Threading.Channels queue and a BackgroundService worker — with the DI lifetime traps, graceful shutdown checks, and durability limits explained.
07 Oct 2026, 09:30 UTC

The endpoint that does too much
A user clicks "Export report" and your API endpoint generates a CSV, uploads it to blob storage, and emails a link. The request takes eight seconds, ties up a thread the whole time, and if the client disconnects or the process recycles mid-flight, the work silently vanishes. Multiply that by a few dozen concurrent users and your p99 latency graph starts telling a story nobody wants to read.
The fix is a classic producer/consumer split: the endpoint validates the request and enqueues a job in milliseconds, and a background worker does the slow part. In .NET you can build this in-process with two pieces that ship in the box — BackgroundService (from Microsoft.Extensions.Hosting) and System.Threading.Channels — without standing up a broker. This post walks through that pattern, the DI lifetime traps that bite people, and the point where you should stop and reach for a real queue instead.
Why not just Task.Run?
The tempting shortcut is _ = Task.Run(() => DoSlowWork()) inside the endpoint. Don't. The host has no idea that task exists. When the app shuts down — a deploy, a container restart, a scale-in event — the runtime won't wait for it, and the job dies mid-write with no log entry and no retry. You also get no backpressure: a burst of requests spawns a burst of concurrent work competing for the same CPU and connection pools as your request pipeline.
A hosted service, by contrast, is a first-class citizen. The host starts it with the app, and on shutdown it signals a CancellationToken and waits (up to a configurable timeout) for ExecuteAsync to return. That gives you a drain window to finish the current job cleanly.
Wiring up a bounded channel and a worker
The channel is the queue. Making it bounded is the important part: when it's full, producers asynchronously wait instead of memory growing without limit. That's backpressure for free.
// Program.cs (run in your ASP.NET Core project; .NET 6+ minimal hosting)
var builder = WebApplication.CreateBuilder(args);
// Singleton so endpoints and the worker share ONE channel instance.
builder.Services.AddSingleton(
Channel.CreateBounded<ExportJob>(new BoundedChannelOptions(capacity: 100)
{
FullMode = BoundedChannelFullMode.Wait
}));
builder.Services.AddHostedService<ExportWorker>();
var app = builder.Build();
app.MapPost("/exports", async (ExportRequest req, Channel<ExportJob> queue, CancellationToken ct) =>
{
// Validate first; enqueueing is the only "work" here.
var job = new ExportJob(req.ReportId, req.Email);
await queue.Writer.WriteAsync(job, ct); // awaits if the queue is full
return Results.Accepted(new { job.ReportId });
});
app.Run();
public record ExportJob(Guid ReportId, string Email);
public record ExportRequest(Guid ReportId, string Email);The worker consumes with ReadAllAsync, which completes the loop when the channel is completed or the token fires:
public class ExportWorker : BackgroundService
{
private readonly Channel<ExportJob> _queue;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ExportWorker> _logger;
public ExportWorker(Channel<ExportJob> queue, IServiceScopeFactory scopeFactory, ILogger<ExportWorker> logger)
=> (_queue, _scopeFactory, _logger) = (queue, scopeFactory, logger);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var job in _queue.Reader.ReadAllAsync(stoppingToken))
{
try
{
// Scoped services (DbContext, etc.) must come from a scope,
// not the constructor — hosted services are singletons.
using var scope = _scopeFactory.CreateScope();
var exporter = scope.ServiceProvider.GetRequiredService<ReportExporter>();
await exporter.ExportAsync(job, stoppingToken);
_logger.LogInformation("Export {ReportId} done", job.ReportId);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
throw; // let shutdown proceed
}
catch (Exception ex)
{
_logger.LogError(ex, "Export {ReportId} failed", job.ReportId);
// Job is dropped here — see the durability section.
}
}
}
}Two details matter more than they look. First, don't inject a DbContext (or any scoped service) into the worker's constructor — the worker lives for the app's lifetime, so you'd capture one stale context forever. Resolve a scope per job via IServiceScopeFactory. Second, honor stoppingToken everywhere. A blocking loop that ignores it will hold shutdown hostage until the host's grace period expires and the process is killed anyway.
Verifying shutdown actually drains
Don't take graceful shutdown on faith — watch it. Run the app locally, enqueue a job with a deliberate 10-second delay in the exporter, then press Ctrl+C while it's processing. You should see the shutdown begin, the in-flight job complete (or abort via the token), and only then the process exit. Also check your target framework's documentation for HostOptions.ShutdownTimeout and BackgroundServiceExceptionBehavior: the defaults for how long the host waits and what an unhandled ExecuteAsync exception does to the host have changed across .NET releases, so confirm them for your version rather than assuming.
A useful load check: hammer /exports with more concurrent requests than your channel capacity and worker throughput. With FullMode.Wait you'll see request latency rise as producers block on a full queue — that's the backpressure working, and it's your cue to return 429 (via TryWrite instead of WriteAsync) if waiting isn't acceptable.
The trade-off: this queue is not durable
An in-memory channel is exactly that — in memory. If the process crashes, recycles, or gets rescheduled by your orchestrator, every queued job is gone. It also doesn't scale out: run three replicas and each has its own private queue, with no shared view of the work.
So the decision rule is roughly: if losing a job means "the user clicks export again," the in-process pattern is a great, low-infrastructure fit. If losing a job means lost money, missed compliance emails, or unfulfilled orders, put the job in a database table or a real broker (Azure Service Bus, RabbitMQ, SQS) and have the worker read from there. The BackgroundService half of this post stays identical either way — only the queue implementation changes.
One more boundary: the worker shares the process with your web requests. CPU-heavy jobs (image resizing, PDF rendering) can still starve request handling. When that becomes measurable, move the same BackgroundService code into a separate Worker Service project and give it its own process and scaling rules.
Where to start
Pick your slowest endpoint that does side work after the user already has their answer. Wrap that work in a job record, add the bounded channel and one hosted worker, and measure the endpoint's latency before and after on your own workload. You'll keep the fast response, gain clean shutdown semantics, and know exactly which limitation — durability — would push you to a broker later.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.