Background Jobs in .NET: BackgroundService, IHostedService, and When You Need Hangfire

"Just run it in the background" is one of those requirements that sounds like an afternoon of work and turns into a design decision with real consequences. Send the welcome email after registration, sync inventory nightly, process uploads off the request path — every real application grows background work. .NET gives you a spectrum of tools for it, and choosing wrong usually means either overbuilding a cron job or underbuilding a payment processor.

Here's the spectrum, from simplest to most capable, and how I choose.

Level 0: The thing not to do

csharp
// ❌ Fire-and-forget from a request handler
_ = Task.Run(() => SendWelcomeEmailAsync(user.Email));
return Ok();

This "works" until it matters. There's no retry if the email service hiccups, exceptions vanish (or take down the process), and an app-pool recycle or deployment kills in-flight work silently. If the work matters enough to run, it matters enough to survive a restart — keep this pattern for genuinely disposable work only (cache warming, best-effort telemetry).

Level 1: BackgroundService for continuous work

BackgroundService (the standard implementation of IHostedService) is the workhorse for always-on loops inside your app process:

csharp
public class OutboxProcessor : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OutboxProcessor> _logger;

    public OutboxProcessor(IServiceScopeFactory scopeFactory,
        ILogger<OutboxProcessor> logger)
        => (_scopeFactory, _logger) = (scopeFactory, logger);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                using var scope = _scopeFactory.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                await ProcessPendingMessagesAsync(db, stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                _logger.LogError(ex, "Outbox processing failed; will retry");
            }
        }
    }
}

// Program.cs
builder.Services.AddHostedService<OutboxProcessor>();

Three details in that snippet are non-negotiable in production, because each one is a bug I've seen live:

  1. The scope factory. A BackgroundService is a singleton; injecting DbContext directly is a captive dependency. Create a scope per iteration.
  2. The catch-all inside the loop. Since .NET 6, an unhandled exception in ExecuteAsync stops the entire host by default. One bad iteration shouldn't kill your app — catch, log, continue.
  3. PeriodicTimer over Task.Delay in a loop — it doesn't drift, and it plays cleanly with cancellation.

Use IHostedService directly (rather than BackgroundService) only when you need precise control over startup/shutdown ordering — e.g., subscribing a message consumer before the app accepts traffic.

Level 2: In-process queues for request offloading

When requests need to hand work to the background, pair a Channel<T> with a consuming BackgroundService:

csharp
public class EmailQueue
{
    private readonly Channel<EmailJob> _channel =
        Channel.CreateBounded<EmailJob>(capacity: 1000);

    public ValueTask EnqueueAsync(EmailJob job, CancellationToken ct = default)
        => _channel.Writer.WriteAsync(job, ct);

    public IAsyncEnumerable<EmailJob> DequeueAllAsync(CancellationToken ct)
        => _channel.Reader.ReadAllAsync(ct);
}

The controller enqueues and returns immediately; the hosted service consumes. Note the bounded channel — an unbounded queue under sustained load is just an out-of-memory error on a timer.

The honest limitation: the queue lives in memory. Deploy, crash, or scale to two instances and you lose or duplicate work. Which brings us to the real dividing line.

The question that decides everything: can you afford to lose a job?

  • Welcome email lost on the deploy that happens once a week? Mildly annoying. In-process is fine.
  • Invoice generation, payment capture, order fulfillment lost? Not acceptable. You need persistence — jobs stored somewhere durable, with retries and visibility.

That's Level 3.

Level 3: Hangfire (or Quartz.NET) for durable jobs

Hangfire stores jobs in your existing database, survives restarts, retries failures with backoff, distributes work across multiple servers, and gives you a dashboard your support team will love:

csharp
builder.Services.AddHangfire(cfg => cfg.UseSqlServerStorage(connectionString));
builder.Services.AddHangfireServer();

// Fire-and-forget — but persisted and retried
_jobs.Enqueue<IEmailService>(svc => svc.SendWelcomeAsync(user.Email));

// Delayed
_jobs.Schedule<IReportService>(svc => svc.SendTrialReminder(user.Id),
    TimeSpan.FromDays(7));

// Recurring (cron)
RecurringJob.AddOrUpdate<IInventoryService>("nightly-sync",
    svc => svc.SyncAsync(), Cron.Daily(2));

One consequence of persistence that surprises teams: jobs will run at least once — sometimes more than once (a retry after a mid-flight crash, two servers racing). Durable job handlers must be idempotent: check whether the email was already sent, upsert instead of insert, make the job safe to repeat.

Quartz.NET is the alternative when your needs are scheduling-centric (complex calendars, misfire policies) rather than queue-centric.

Level 4: When to leave the process entirely

If background work needs independent scaling, survives app redesigns, or crosses service boundaries, move it to real infrastructure: Azure Service Bus / RabbitMQ with a dedicated worker service (dotnet new worker), or Azure Functions for event-driven bursts. Same idempotency rules, bigger operational toolbox — dead-letter queues, per-queue scaling, isolation from your web app's deploy cycle.

How I choose, in one list

  • Periodic maintenance inside one app (cache refresh, cleanup): BackgroundService + PeriodicTimer.
  • Offload from requests, loss acceptable: bounded Channel + BackgroundService.
  • Loss not acceptable, or you need retries/scheduling/visibility: Hangfire on your existing database. This is the default for most business apps.
  • Independent scale or cross-service messaging: message broker + worker service.

Climb the ladder only as far as the work's importance demands — but know which rung you're standing on, and what falls off when the process restarts.

Where to go next

See it in practice: the Statement Delivery Platform case study has BackgroundService workers that scale from zero to 400, leader election on a lease table, and a PostgreSQL queue chosen over a broker.