⚙️ Hands-On Tutorial

.NET 8 Background Jobs: IBackgroundTaskQueue, BackgroundService & Production-Ready Patterns

Most ASP.NET Core applications eventually need to run work outside the request/response cycle — sending emails, generating reports, processing uploads. The common first approach is Task.Run from inside a controller. It works until it doesn't: no retry logic, no graceful shutdown, no backpressure, and no visibility into what is failing.

This tutorial builds the right foundation: a production-ready background job runner inside your ASP.NET Core 8 process, using a bounded channel as its queue with retry logic, graceful shutdown, structured logging with correlation IDs, and a clean adapter interface so you can swap in Redis, Azure Queue Storage, or RabbitMQ later without rewriting a single line of job code.

What You'll Build

A background job runner (tutorials/dotnet-8-essentials/BackgroundJobsHostedServiceQueue/) covering every production concern:

  • IBackgroundTaskQueue — interface over a bounded Channel<WorkItem> with configurable capacity and backpressure
  • QueuedHostedService — a BackgroundService that dequeues and executes work items with per-job exception isolation
  • Retry policy — exponential backoff with configurable max attempts and poison-job dead-lettering
  • Real examples — email sender queue (fake SMTP) and a timer-based scheduled enqueue alongside an HTTP endpoint that enqueues on demand
  • Structured logging and metrics — per-job correlation IDs in log scopes, counters for queued, processing, success, and failure counts
  • Graceful shutdown — drain window, IHostApplicationLifetime integration, cancellation token propagation to in-flight jobs
  • Pitfall catalogue — scoped services in singleton workers, DbContext lifetime, async void, and swallowed exceptions with correct patterns for each
  • Upgrade path — adapter interface for swapping to Redis, Azure Queue, or RabbitMQ without touching job code

Project Setup & Work Item Model

A minimal ASP.NET Core Web API that exposes endpoints to enqueue jobs and an admin endpoint to observe queue depth. The application logic is deliberately thin — the focus is the background infrastructure, not the domain.

Terminal — Scaffold the Project
dotnet new webapi -n BackgroundJobsHostedServiceQueue --no-https --minimal
cd BackgroundJobsHostedServiceQueue

# No additional NuGet packages required for the core pattern
# System.Threading.Channels  — bounded channel (in .NET 8 BCL)
# Microsoft.Extensions.Hosting — BackgroundService base class (in .NET 8 BCL)
# System.Diagnostics.Metrics  — counters for observability (in .NET 8 BCL)

dotnet run
# Verify: Application started. Press Ctrl+C to shut down.

Target Folder Layout

Project Structure
BackgroundJobsHostedServiceQueue/
├── Jobs/
│   ├── WorkItem.cs               # job envelope: delegate + metadata
│   ├── IBackgroundTaskQueue.cs   # queue abstraction (swap-in point)
│   ├── BoundedChannelQueue.cs   # bounded Channel implementation
│   ├── QueuedHostedService.cs   # BackgroundService consumer loop
│   ├── RetryPolicy.cs            # exponential backoff + poison handling
│   └── JobMetrics.cs             # System.Diagnostics.Metrics counters
├── Services/
│   ├── IEmailSender.cs
│   └── FakeEmailSender.cs        # logs instead of sending
├── appsettings.json
└── Program.cs

The Work Item Model

Jobs/WorkItem.cs — The Job Envelope
// A work item is a delegate + metadata envelope.
// Using a Func keeps the queue decoupled from specific job types —
// any async operation can be enqueued without a base class or marker interface.

public sealed class WorkItem
{
    public Guid           JobId         { get; init; } = Guid.NewGuid();
    public string         JobName       { get; init; } = "unnamed";
    public int            AttemptNumber { get; set;  } = 1;
    public DateTimeOffset EnqueuedAt    { get; init; } = DateTimeOffset.UtcNow;

    // The actual work — receives IServiceProvider for DI and a CancellationToken
    public required Func<IServiceProvider, CancellationToken, Task> ExecuteAsync { get; init; }
}

public static class WorkItems
{
    public static WorkItem Create(
        string name,
        Func<IServiceProvider, CancellationToken, Task> work) =>
        new() { JobName = name, ExecuteAsync = work };
}
Return the JobId to the Caller at Enqueue Time

Generate the JobId at enqueue time and include it in the HTTP response body — not just inside the worker. When an endpoint enqueues an email job, the response should include the JobId so the caller can log it, correlate it with the job's eventual log entries, or pass it to a status-check endpoint. A JobId that only exists inside the worker is only useful after the job starts; one returned from the enqueue call is useful from the moment the job is created.

BackgroundService vs External Schedulers: Decision Rules

Not every background job belongs in a BackgroundService. The right tool depends on whether you need durability, distributed execution, a management dashboard, or complex scheduling that an in-process queue cannot provide.

Decision Matrix: BackgroundService vs Hangfire vs Cloud Queue
USE BackgroundService WHEN:
  - Work is fire-and-forget: emails, webhooks, thumbnail generation
  - Job loss on restart is acceptable (transient, re-creatable work)
  - Single-instance or each instance has its own independent job stream
  - Zero external infrastructure dependencies required
  - Volume is manageable within one process (hundreds/minute, not millions)

CONSIDER Hangfire / Quartz WHEN:
  - Jobs must survive application restarts (persistent job storage required)
  - You need a UI dashboard to view, retry, and delete jobs manually
  - Complex scheduling: cron expressions, calendar-aware, timezone-sensitive
  - Multiple instances must not double-process the same job (distributed lock needed)
  - Long-running jobs that outlast a single deployment cycle

USE Cloud Queue (Azure Queue, SQS, RabbitMQ) WHEN:
  - Cross-service: producer and consumer are separate services
  - Durable delivery guarantees at infrastructure level required
  - Fan-out: one event triggers multiple independent consumers
  - Volume: millions of messages per hour

RULE OF THUMB:
  Start with BackgroundService — zero cost, zero dependencies.
  When you build persistence + retry dashboard + distributed locking ON TOP
  of BackgroundService, you have outgrown it. That is the signal to migrate.
Task.Run Is Not a Background Service

Calling _ = Task.Run(() => DoWorkAsync()) from a controller fires a task completely invisible to the .NET host: no cancellation on shutdown, no exception surfacing to logs, no backpressure, and no lifecycle management. If the application pool recycles or the container restarts mid-execution, the task vanishes silently. Task.Run is appropriate for CPU-bound parallelism on the current request — never for background work that should outlive the request.

The Core Pattern: IBackgroundTaskQueue & Bounded Channel

The queue abstraction accepts work from producers (HTTP endpoints, timers, other services) and delivers it to the single consumer (the BackgroundService worker). A Channel<WorkItem> with bounded capacity is the correct modern implementation.

Jobs/IBackgroundTaskQueue.cs — The Abstraction
public interface IBackgroundTaskQueue
{
    // Enqueue a work item. May suspend async if queue is full (backpressure).
    ValueTask EnqueueAsync(WorkItem workItem, CancellationToken ct = default);

    // Dequeue the next work item. Suspends async until an item is available.
    // Returns null when the channel is closed (application shutdown).
    ValueTask<WorkItem?> DequeueAsync(CancellationToken ct = default);

    // Current items waiting — for metrics and health checks
    int Count { get; }
}
Jobs/BoundedChannelQueue.cs — Channel Implementation with Backpressure
public sealed class BoundedChannelQueue : IBackgroundTaskQueue
{
    private readonly Channel<WorkItem> _channel;

    public BoundedChannelQueue(IOptions<BackgroundJobOptions> options)
    {
        var capacity = options.Value.Capacity;

        // BoundedChannelFullMode.Wait = backpressure:
        // EnqueueAsync suspends the producer until the consumer makes room.
        // This prevents unbounded memory growth when the consumer falls behind.
        // Alternative FullModes for drop scenarios (telemetry sampling, etc.):
        //   DropOldest | DropNewest | DropWrite
        _channel = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(capacity)
        {
            FullMode                    = BoundedChannelFullMode.Wait,
            SingleReader                = true,   // only the worker reads — enables optimisations
            SingleWriter                = false,  // many producers write concurrently
            AllowSynchronousContinuations = false // avoid thread-pool starvation
        });
    }

    // Option A — call it in BoundedChannelQueue (requires injecting JobMetrics there)
// Option B — simplest: call it at the enqueue call-site in the HTTP endpoint:
await queue.EnqueueAsync(workItem, ct);
metrics.JobQueued(); // add this line after every EnqueueAsync call

    public async ValueTask<WorkItem?> DequeueAsync(CancellationToken ct = default)
    {
        try   { return await _channel.Reader.ReadAsync(ct); }
        catch (OperationCanceledException) { return null; } // shutdown
        catch (ChannelClosedException)     { return null; } // channel closed
    }

    public int Count => _channel.Reader.Count;
}

public sealed class BackgroundJobOptions
{
    public int Capacity             { get; set; } = 100;
    public int MaxAttempts          { get; set; } = 3;
    public int DrainTimeoutSeconds  { get; set; } = 30;
}
Program.cs — DI Registration
builder.Services.Configure<BackgroundJobOptions>(
    builder.Configuration.GetSection("BackgroundJobs"));

// Singleton: shared between all producers and the single worker
builder.Services.AddSingleton<IBackgroundTaskQueue, BoundedChannelQueue>();

// AddHostedService registers the worker as IHostedService + singleton
builder.Services.AddHostedService<QueuedHostedService>();

// Scoped: created fresh per job execution via IServiceScopeFactory
builder.Services.AddScoped<IEmailSender, FakeEmailSender>();

builder.Services.AddSingleton<JobMetrics>();
builder.Services.AddSingleton();
Bounded Capacity Prevents Memory Blowup Under Load

An unbounded Channel.CreateUnbounded<T>() will accept work items indefinitely — even if the consumer is stopped. In a scenario where an HTTP endpoint enqueues a job per request and the consumer falls behind under load, an unbounded queue silently accumulates hundreds of thousands of items in memory until the process crashes. A bounded queue with BoundedChannelFullMode.Wait applies backpressure: the endpoint's EnqueueAsync call suspends until the consumer makes room, surfacing the overload condition to callers rather than hiding it behind growing heap consumption.

The Worker: Consumer Loop, Cancellation & Exception Isolation

The BackgroundService consumer loop dequeues work items one at a time, executes them with a fresh DI scope, and isolates exceptions so one failing job does not stop the worker from processing the next item in the queue.

Jobs/QueuedHostedService.cs — The Consumer Loop
public sealed class QueuedHostedService(
    IBackgroundTaskQueue          queue,
    IServiceScopeFactory          scopeFactory,
    JobMetrics                    metrics,
    RetryPolicy                   retryPolicy,
    ILogger<QueuedHostedService>  logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        logger.LogInformation("Background job worker started");

        while (!stoppingToken.IsCancellationRequested)
        {
            // Suspends here until an item arrives or shutdown is signalled
            var workItem = await queue.DequeueAsync(stoppingToken);
            if (workItem is null) break; // shutdown or channel closed

            // Per-job structured log scope — JobId on every log event for this job
            using var logScope = logger.BeginScope(new Dictionary<string, object>
            {
                ["JobId"]      = workItem.JobId,
                ["JobName"]    = workItem.JobName,
                ["AttemptNum"] = workItem.AttemptNumber
            });

            metrics.JobStarted();
            logger.LogInformation("Starting job {JobName} (attempt {Attempt})",
                workItem.JobName, workItem.AttemptNumber);

            // Fresh DI scope per job — critical for DbContext and scoped services
            await using var scope = scopeFactory.CreateAsyncScope();

            try
            {
                await retryPolicy.ExecuteAsync(
                    workItem, scope.ServiceProvider, queue, stoppingToken);

                metrics.JobSucceeded();
                logger.LogInformation("Job {JobName} completed", workItem.JobName);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                // Isolated: one job failure does not stop the worker
                // OperationCanceledException propagates to exit the loop cleanly
                metrics.JobFailed();
                logger.LogError(ex, "Job {JobName} failed after all retries", workItem.JobName);
            }
        }

        logger.LogInformation("Background job worker stopped");
    }
}
Never Swallow OperationCanceledException in the Worker Loop

The when (ex is not OperationCanceledException) exception filter is deliberate and critical. OperationCanceledException is how .NET signals that a CancellationToken was cancelled — here, the host shutdown signal. Catching and swallowing it causes the worker loop to continue running after the host has asked it to stop, delaying or preventing clean application shutdown. Always let OperationCanceledException propagate from ExecuteAsync so the BackgroundService infrastructure can observe it and shut down cleanly.

Reliability: Retry, Exponential Backoff & Poison-Job Handling

Transient failures — a brief database hiccup, a momentary SMTP timeout — should not permanently fail a job. A retry policy with backoff gives transient conditions time to resolve. A maximum attempt count ensures permanently failing jobs do not loop forever.

Jobs/RetryPolicy.cs — Exponential Backoff with Poison-Job Handling
public sealed class RetryPolicy(
    IOptions<BackgroundJobOptions> options,
    ILogger<RetryPolicy>          logger)
{
    private readonly int _maxAttempts = options.Value.MaxAttempts;

    public async Task ExecuteAsync(
        WorkItem          workItem,
        IServiceProvider  services,
        IBackgroundTaskQueue queue,
        CancellationToken ct)
    {
        try
        {
            await workItem.ExecuteAsync(services, ct);
        }
        catch (OperationCanceledException)
        {
            throw; // never retry on cancellation — propagate shutdown signal
        }
        catch (Exception ex)
        {
            if (workItem.AttemptNumber >= _maxAttempts)
            {
                // Poison job: exceeded max attempts
                logger.LogError(ex,
                    "Job {JobName} ({JobId}) exceeded {Max} attempts. Discarding.",
                    workItem.JobName, workItem.JobId, _maxAttempts);
                OnPoisonJob(workItem, ex); // extension point — see below
                return;
            }

            // Exponential backoff: 2s, 4s, 8s, ... capped at 60s
            var delay = TimeSpan.FromSeconds(
                Math.Min(Math.Pow(2, workItem.AttemptNumber), 60));

            logger.LogWarning(ex,
                "Job {JobName} attempt {Attempt} failed. Retrying after {Delay}s.",
                workItem.JobName, workItem.AttemptNumber, delay.TotalSeconds);

            await Task.Delay(delay, ct); // respects cancellation during backoff

            // Re-enqueue with incremented attempt counter
            await queue.EnqueueAsync(workItem with { AttemptNumber = workItem.AttemptNumber + 1 }, ct);
        }
    }

    private static void OnPoisonJob(WorkItem workItem, Exception ex)
    {
        // Extension points:
        // - Write to a dead-letter table
        // - Publish a PoisonJobEvent to a monitoring channel
        // - Fire a PagerDuty / OpsGenie alert
    }
}
Backoff Delay Blocks the Worker From Processing Other Jobs

await Task.Delay(delay, ct) holds the worker's execution path idle for the entire backoff duration. During this wait, the worker cannot process any other queued jobs — the queue backs up. For low-volume queues this is acceptable and simpler. For high-throughput scenarios, consider re-enqueueing with a NotBefore timestamp and having the worker skip items that are not yet due, or dedicate a separate delay queue. Start with the simpler delay-in-worker approach; optimise only when queue depth metrics reveal it is causing unacceptable backup.

Do Not Retry Non-Transient Exceptions

Retrying a ValidationException or UnauthorizedException three times before discarding the job wastes backoff time and obscures the real problem. Consider classifying exceptions before retrying: catch known transient types (DbUpdateException, HttpRequestException, SocketException) and retry those specifically; treat unknown exception types as potentially non-retryable and dead-letter them immediately with a warning. This prevents a misconfigured job from consuming retry budget that well-behaved transient failures need.

Real Examples: Email Queue & Scheduled Enqueue

Two concrete job patterns covering the most common background work scenarios: an HTTP-triggered email sender queue and a timer-based scheduled enqueue with PeriodicTimer.

Example 1 — Email Sender Queue

Services/FakeEmailSender.cs & HTTP Enqueue Endpoint
// Services/IEmailSender.cs
public interface IEmailSender
{
    Task SendAsync(string to, string subject, string body, CancellationToken ct);
}

// Services/FakeEmailSender.cs — logs instead of sending; swap for real SMTP later
public sealed class FakeEmailSender(ILogger<FakeEmailSender> logger) : IEmailSender
{
    public async Task SendAsync(string to, string subject, string body, CancellationToken ct)
    {
        await Task.Delay(200, ct); // simulate network latency
        logger.LogInformation("EMAIL to={To} subject={Subject}", to, subject);
    }
}

// HTTP endpoint — enqueues the email job and returns the JobId immediately
app.MapPost("/send-email", async (
    SendEmailRequest      req,
    IBackgroundTaskQueue  queue,
    CancellationToken     ct) =>
{
    var workItem = WorkItems.Create("send-email", async (services, jobCt) =>
    {
        var sender = services.GetRequiredService<IEmailSender>();
        await sender.SendAsync(req.To, req.Subject, req.Body, jobCt);
    });

    await queue.EnqueueAsync(workItem, ct);

    // Accepted (202): the job is queued, not yet complete
    return Results.Accepted($"/jobs/{workItem.JobId}", new { workItem.JobId });
});

public record SendEmailRequest(string To, string Subject, string Body);

Example 2 — Timer-Based Scheduled Enqueue

Jobs/ReportSchedulerService.cs — PeriodicTimer Enqueue
// A second BackgroundService that enqueues jobs on a schedule.
// Keeping scheduling and execution in separate services
// lets you unit-test each concern independently.

public sealed class ReportSchedulerService(
    IBackgroundTaskQueue            queue,
    ILogger<ReportSchedulerService> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // PeriodicTimer: does not drift — interval measured from tick start,
        // not from the end of the previous work item
        using var timer = new PeriodicTimer(TimeSpan.FromHours(1));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            logger.LogInformation("Scheduling hourly report generation");

            var workItem = WorkItems.Create("generate-hourly-report", async (services, ct) =>
            {
                var reportService = services.GetRequiredService<IReportService>();
                await reportService.GenerateHourlyReportAsync(ct);
            });

            await queue.EnqueueAsync(workItem, stoppingToken);
        }
    }
}
Use PeriodicTimer, Not Task.Delay in a Loop

Task.Delay(interval) in a while (true) loop drifts: if your work takes 5 seconds and the interval is 60 seconds, the effective period is 65 seconds. Over hours this compounds. PeriodicTimer (introduced in .NET 6) measures the interval from the start of each tick, giving you a consistent firing rate regardless of how long the work takes. It is also cancellation-token-aware: WaitForNextTickAsync returns false when the token fires, letting the while loop exit cleanly.

Observability: Correlation IDs, Structured Logs & Counters

Without observability, a background job runner is a black box. Structured log scopes with correlation IDs let you trace a specific job's full execution; numeric counters tell you the overall health of the queue at a glance.

Jobs/JobMetrics.cs — System.Diagnostics.Metrics Counters
public sealed class JobMetrics
{
    private readonly Counter<long>        _queued;
    private readonly Counter<long>        _started;
    private readonly Counter<long>        _succeeded;
    private readonly Counter<long>        _failed;
    private readonly ObservableGauge<int> _queueDepth;

    public JobMetrics(IMeterFactory meterFactory, IBackgroundTaskQueue queue)
    {
        var meter = meterFactory.Create("BackgroundJobs");

        _queued     = meter.CreateCounter<long>("jobs.queued",
                          description: "Total jobs enqueued");
        _started    = meter.CreateCounter<long>("jobs.started",
                          description: "Total jobs started by the worker");
        _succeeded  = meter.CreateCounter<long>("jobs.succeeded",
                          description: "Total jobs completed successfully");
        _failed     = meter.CreateCounter<long>("jobs.failed",
                          description: "Total jobs failed after all retry attempts");

        // Observable gauge: value is read on each metrics collection cycle
        _queueDepth = meter.CreateObservableGauge<int>(
            "jobs.queue_depth",
            () => queue.Count,
            description: "Current number of jobs waiting in the queue");
    }

    public void JobQueued()    => _queued.Add(1);
    public void JobStarted()   => _started.Add(1);
    public void JobSucceeded() => _succeeded.Add(1);
    public void JobFailed()    => _failed.Add(1);
}
Queue Depth Is Your Most Actionable Alert Metric

Queue depth — items waiting to be processed — is the single metric most worth alerting on. A steadily growing queue depth means your consumer is slower than your producers, which will eventually cause backpressure to bubble up to HTTP endpoints (callers suspending on EnqueueAsync) and then time out. Set an alert at 50% of your configured capacity. A queue at capacity is a production incident in progress. Compare queue depth against the jobs.started rate to determine whether the problem is slow consumers or a burst of fast producers.

Expose Queue Depth on a Health Check Endpoint

Register a custom health check that reports Degraded when queue depth exceeds 75% of capacity and Unhealthy when it reaches 95%. Kubernetes readiness probes pointing at /health/ready will stop routing new traffic to an instance that is already overwhelmed — preventing the queue from growing further under load. This turns a silent queue backup into a visible, actionable signal that your orchestration layer can respond to automatically.

Graceful Shutdown: Drain Window & Cancellation Propagation

Graceful shutdown means finishing work already started before the process exits — not dropping in-flight jobs. The .NET host provides two mechanisms: a cancellation token passed to ExecuteAsync, and a configurable shutdown timeout.

Program.cs — Shutdown Timeout & Drain Pattern
builder.Services.Configure<HostOptions>(options =>
{
    // How long the host waits for StopAsync to complete before forcibly killing.
    // Default is 30s in .NET 8. Set to at least your longest expected job runtime.
    options.ShutdownTimeout = TimeSpan.FromSeconds(60);
});

// Status endpoint — observe lifecycle state without a profiler
app.MapGet("/jobs/status", (
    IBackgroundTaskQueue     queue,
    IHostApplicationLifetime lifetime) => new
{
    QueueDepth  = queue.Count,
    IsRunning   = !lifetime.ApplicationStopping.IsCancellationRequested,
    IsStopping  = lifetime.ApplicationStopping.IsCancellationRequested,
    IsStopped   = lifetime.ApplicationStopped.IsCancellationRequested
});

// Drain pattern: stop accepting new work when shutdown begins,
// but allow the worker to finish the currently in-flight job
app.MapPost("/send-email", async (
    SendEmailRequest         req,
    IBackgroundTaskQueue     queue,
    IHostApplicationLifetime lifetime,
    CancellationToken        ct) =>
{
    if (lifetime.ApplicationStopping.IsCancellationRequested)
        return Results.StatusCode(503); // Service Unavailable — draining

    var workItem = WorkItems.Create("send-email", async (services, jobCt) =>
    {
        var sender = services.GetRequiredService<IEmailSender>();
        await sender.SendAsync(req.To, req.Subject, req.Body, jobCt);
    });

    await queue.EnqueueAsync(workItem, ct);
    return Results.Accepted($"/jobs/{workItem.JobId}", new { workItem.JobId });
});
Drain vs Stop-Now: Two Shutdown Modes

Drain: the worker finishes the job currently in-flight, then observes the cancellation token and exits the dequeue loop. Jobs still in the queue at shutdown are lost (in-memory). This is the default when the shutdown timeout is long enough to let the in-flight job complete. Stop-now: the cancellation token fires mid-job, well-behaved jobs check it at safe checkpoints and stop early. For in-memory queues, prefer drain — configure a shutdown timeout longer than your longest expected job and return 503 on new enqueue requests once stopping begins. For durable queues (Redis, Azure), stop-now is safe because unacknowledged messages return to the queue automatically after restart.

Pitfalls, Common Mistakes & the Upgrade Path to Durable Queues

The four most common mistakes when building BackgroundService workers — and the adapter pattern that lets you graduate to a durable queue when the time comes, without rewriting job code.

Pitfall Catalogue with Correct Patterns
// PITFALL 1: Injecting scoped service into singleton BackgroundService
public class BadWorker(CatalogDbContext db) : BackgroundService { } // stale DbContext

// CORRECT: IServiceScopeFactory + create scope per job
public class GoodWorker(IServiceScopeFactory factory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            await using var scope = factory.CreateAsyncScope();
            var db = scope.ServiceProvider.GetRequiredService<CatalogDbContext>();
            // scope disposed at end of using block — DbContext returned to pool
        }
    }
}

// PITFALL 2: async void — unobservable exception crashes the process
private async void BadProcessAsync(WorkItem item) { await ...; } // crash on exception

// CORRECT: async Task — exception bubbles to the awaiting consumer loop
private async Task GoodProcessAsync(WorkItem item, CancellationToken ct) { await ...; }

// PITFALL 3: Swallowing exceptions in the job delegate
var bad = WorkItems.Create("job", async (services, ct) =>
{
    try { await DoWorkAsync(ct); }
    catch { } // swallowed — no retry, no logging, silent data loss
});

// CORRECT: let exceptions propagate to RetryPolicy.ExecuteAsync
var good = WorkItems.Create("job", async (services, ct) =>
{
    await DoWorkAsync(ct); // exceptions bubble to the retry/logging layer
});

// PITFALL 4: Catching OperationCanceledException and continuing the loop
catch (Exception) { /* ... */ }  // catches OCE too — worker never stops cleanly

// CORRECT: filter OCE out of the catch block
catch (Exception ex) when (ex is not OperationCanceledException) { /* ... */ }

The Upgrade Path — Adapter Interface

Swapping BoundedChannelQueue for a Redis Adapter — Zero Job Code Changes
// Because QueuedHostedService only depends on IBackgroundTaskQueue,
// replacing the in-memory implementation requires zero changes to the worker.

public sealed class RedisBackgroundTaskQueue : IBackgroundTaskQueue
{
    private readonly IDatabase      _db;
    private readonly string         _key = "jobs:queue";
    private readonly IJobSerializer _serializer;

    public RedisBackgroundTaskQueue(IConnectionMultiplexer redis, IJobSerializer s)
    {
        _db = redis.GetDatabase(); _serializer = s;
    }

    public async ValueTask EnqueueAsync(WorkItem w, CancellationToken ct = default)
        => await _db.ListRightPushAsync(_key, _serializer.Serialize(w));

    public async ValueTask<WorkItem?> DequeueAsync(CancellationToken ct = default)
    {
        var result = await _db.ListLeftPopAsync(_key);
        return result.IsNullOrEmpty ? null : _serializer.Deserialize(result!);
    }

    // ListLength is synchronous on IDatabase — acceptable for a low-frequency
// health/metrics read, but note it issues a synchronous network call.
// For high-frequency polling, cache the value or use a local counter instead.
public int Count => (int)_db.ListLength(_key); // sync network call — see note above
}

// One line change in Program.cs — all job and worker code untouched:
// BEFORE: builder.Services.AddSingleton<IBackgroundTaskQueue, BoundedChannelQueue>();
// AFTER:
builder.Services.AddSingleton<IBackgroundTaskQueue, RedisBackgroundTaskQueue>();

// The same pattern applies to Azure Queue Storage, RabbitMQ, or any message broker.
// Implement IBackgroundTaskQueue, register in DI — done.
Delegate Jobs Cannot Be Serialized — Plan the Job Model for Durable Queues

The in-memory BoundedChannelQueue stores WorkItem delegate objects in process memory — no serialization needed. The moment you move to Redis or Azure Queue, work items must be serialized to bytes. A Func<IServiceProvider, CancellationToken, Task> delegate is code, not data — it cannot be serialized. For durable queues, replace the delegate with typed job descriptor records (e.g. record SendEmailJob(string To, string Subject, string Body)) that can be JSON-serialized. The adapter interface isolates this change from the rest of the application, but the job model must be designed with serializability in mind from the start if durable queues are on your roadmap.

Frequently Asked Questions

What is the difference between IHostedService and BackgroundService in .NET 8?

IHostedService is the low-level interface with StartAsync and StopAsync called by the .NET host during startup and shutdown. BackgroundService is an abstract base class implementing IHostedService that adds a long-running ExecuteAsync method for continuous loops, complete with a CancellationToken that fires when the host shuts down. For most background job scenarios, derive from BackgroundService — it handles the IHostedService plumbing. Use IHostedService directly only when you need precise control over startup and shutdown sequencing that BackgroundService does not expose.

Why use a bounded Channel instead of ConcurrentQueue or BlockingCollection?

System.Threading.Channels.Channel<T> provides async-native ReadAsync and WriteAsync that integrate cleanly with async/await and cancellation tokens — no polling loop required. Unlike BlockingCollection, it never blocks a thread-pool thread: it suspends the async method until data or capacity is available. The bounded variant adds a configurable capacity limit with full-mode behaviour (wait for backpressure, or drop), making it the correct modern primitive for producer-consumer background queues in .NET.

How do I use a scoped service like DbContext inside a singleton BackgroundService?

Inject IServiceScopeFactory and create a new async scope per job: await using var scope = factory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService<CatalogDbContext>();. The scope and DbContext are disposed when the using block exits, returning the connection to the pool. Never inject a scoped service directly into a singleton — it becomes captured for the singleton's lifetime, producing stale state, leaked connections, and data corruption bugs.

What happens to in-flight jobs when the application shuts down?

The host cancels the token passed to ExecuteAsync and waits up to HostOptions.ShutdownTimeout (default 30 seconds, configurable) for the worker to complete. Jobs respecting the cancellation token finish at a safe checkpoint; jobs ignoring it are forcibly terminated at timeout. Items still in the in-memory queue at shutdown are permanently lost — acceptable for transient work. For durability across restarts, move to a persistent queue via the adapter interface pattern in Section 9.

Should I use BackgroundService or Hangfire for production background jobs?

Use BackgroundService for transient fire-and-forget work where job loss on restart is acceptable and you want zero external infrastructure. Use Hangfire or Quartz when jobs must survive restarts, you need a management dashboard to view and retry failures, or you need complex cron scheduling. Start with BackgroundService; migrate when you find yourself building persistence, distributed locking, and a retry UI on top of it — that is the signal you have outgrown it.

Why should I never use async void in a BackgroundService?

async void methods are fire-and-forget — the caller cannot await them or observe their exceptions. Any exception thrown inside an async void propagates to the thread pool and crashes the entire process — not just the worker. The BackgroundService never sees it and cannot log or retry it. Always use async Task and await the result in the consumer loop so exceptions surface where you have catch blocks, logging, and retry logic in place.

How do I add correlation IDs to background job logs?

Generate a JobId (GUID) at enqueue time and carry it in the WorkItem. In the consumer loop, open a structured logging scope: using (_logger.BeginScope(new Dictionary<string, object> { ["JobId"] = workItem.JobId })) { ... }. With structured sinks (Serilog, Application Insights), every log event inside that scope carries the JobId as a queryable property — letting you filter all log events for a specific job execution by its ID, even when many jobs are executing concurrently and interleaving their log output.

Back to Tutorials