Background tasks in ASP.NET Core run within the same process as the web server, hosted by the same generic host. IHostedService is the base interface for services that start with the application. BackgroundService (inheriting IHostedService) provides a simpler pattern for long-running async loops. System.Threading.Channels is the high-performance in-process queue for passing work from HTTP handlers to background workers — zero network overhead, type-safe, cancellation-aware, and backpressure-capable.
BackgroundService with Channel Queue
// ── Background work item ───────────────────────────────────────────────────
public record EmailWorkItem(
string To, string Subject, string TemplatePath, object Model);
// ── Channel-backed email queue ─────────────────────────────────────────────
public class EmailQueue
{
private readonly Channel<EmailWorkItem> _channel =
Channel.CreateBounded<EmailWorkItem>(new BoundedChannelOptions(500)
{
FullMode = BoundedChannelFullMode.Wait, // backpressure: wait if full
SingleReader = true,
});
public ValueTask EnqueueAsync(EmailWorkItem item, CancellationToken ct = default)
=> _channel.Writer.WriteAsync(item, ct);
public IAsyncEnumerable<EmailWorkItem> ReadAllAsync(CancellationToken ct)
=> _channel.Reader.ReadAllAsync(ct);
}
// ── Background email sender ────────────────────────────────────────────────
public class EmailSenderService(
EmailQueue queue,
IServiceScopeFactory scopeFactory,
ILogger<EmailSenderService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Email sender service started.");
await foreach (var item in queue.ReadAllAsync(stoppingToken))
{
try
{
// Create a DI scope for Scoped services (IFluentEmail is Scoped)
using var scope = scopeFactory.CreateScope();
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
await emailService.SendAsync(item.To, item.Subject,
item.TemplatePath, item.Model, stoppingToken);
logger.LogInformation("Email sent to {To}: {Subject}", item.To, item.Subject);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Failed to send email to {To}", item.To);
// In production: store failed item in database for retry
}
}
logger.LogInformation("Email sender service stopping.");
}
}
// ── Registration ──────────────────────────────────────────────────────────
builder.Services.AddSingleton<EmailQueue>();
builder.Services.AddHostedService<EmailSenderService>();
// ── Usage — enqueue from controller (fire and return) ─────────────────────
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct)
{
var user = await _userService.CreateAsync(request, ct);
// Fire-and-forget email — returns immediately
await _emailQueue.EnqueueAsync(new EmailWorkItem(
user.Email, "Confirm your account",
"Templates/Confirmation.cshtml",
new { user.DisplayName, ConfirmUrl = GenerateConfirmUrl(user) }), ct);
return Created($"/api/users/{user.Id}", user.ToDto()); // 201 immediately
}
BackgroundService.ExecuteAsync() receives a CancellationToken that is triggered when the host is shutting down. The channel’s ReadAllAsync(stoppingToken) automatically stops iterating when the token is cancelled. For graceful shutdown, the host waits up to ShutdownTimeout (default 5 seconds) for hosted services to stop. If the email worker is processing a batch when shutdown is triggered, it should finish the current item and stop — do not drain the entire queue during shutdown unless you extend the timeout.IServiceScopeFactory inside BackgroundService.ExecuteAsync() to create scopes for Scoped services. The BackgroundService is registered as a Singleton (started once at app startup), so its constructor cannot directly receive Scoped services. Create a new scope per work item (using var scope = scopeFactory.CreateScope()) to resolve Scoped services like DbContext or IEmailService with correct lifetime management.Channel<T> is in-process — if the application restarts, any queued items not yet processed are lost. For work items that must not be lost (email confirmations, payment notifications), persist them to a database before enqueuing and mark as processed after successful completion. Hangfire (Lesson 5) handles this automatically using SQL Server storage. For non-critical work (analytics events, search index updates), the Channel approach is simpler and sufficient.Recurring Background Task
// ── Recurring task — clean up expired tokens every hour ────────────────────
public class TokenCleanupService(
IServiceScopeFactory scopeFactory,
ILogger<TokenCleanupService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var deleted = await db.RefreshTokens
.Where(t => t.ExpiresAt < DateTime.UtcNow)
.ExecuteDeleteAsync(stoppingToken);
if (deleted > 0)
logger.LogInformation("Cleaned up {Count} expired tokens.", deleted);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Token cleanup failed.");
}
// Run every hour
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
}
Common Mistakes
Mistake 1 — Directly injecting Scoped services into BackgroundService (captive dependency)
❌ Wrong — public EmailSenderService(AppDbContext db) — Scoped DbContext captured in Singleton.
✅ Correct — inject IServiceScopeFactory; create a new scope per work item.
Mistake 2 — Using Channel without backpressure (unbounded memory growth)
❌ Wrong — Channel.CreateUnbounded<T>(); slow consumer; queue grows unbounded; OOM.
✅ Correct — use Channel.CreateBounded<T>(capacity) with FullMode.Wait for backpressure.