Start Debugging

BackgroundService vs IHostedService vs Hangfire for background jobs in .NET 11

Pick BackgroundService for in-process loops, raw IHostedService when you need fine lifecycle control, and Hangfire when jobs must survive a restart. A decision matrix with code and the one gotcha that picks for you.

For background work in a .NET 11 app, the short answer is: use BackgroundService for continuous in-process loops and queue consumers, drop down to a raw IHostedService only when you need explicit ordered startup or shutdown hooks, and reach for Hangfire the moment a job has to survive a process restart or be scheduled for “next Tuesday at 2am.” The first two are the same hosting primitive at different altitudes and cost you nothing extra. Hangfire is a separate dependency with a database behind it, and that database is exactly what you are paying for. This post builds the decision matrix, shows the minimal code for each, and points at the single requirement — durability — that usually makes the call for you.

All examples target .NET 11 and C# 14. Hangfire examples use Hangfire 1.8.x (Hangfire.AspNetCore plus Hangfire.SqlServer).

The feature matrix

This is the table you came for. Read the “Durable across restart” row first; it is the one that splits the field.

FeatureIHostedServiceBackgroundServiceHangfire
Built into .NET 11yesyesno (NuGet + storage)
Extra infrastructurenonenoneSQL Server / Redis / Postgres
Lifecycle surfaceStartAsync/StopAsyncone ExecuteAsyncnone (you enqueue jobs)
Best forstartup/shutdown stepslong-running loopsone-off and scheduled jobs
Survives a process restartnonoyes
Retries on failureyou write themyou write themautomatic, configurable
Scheduling (cron, delay)you write ityou write itbuilt in
Runs across multiple instancesruns on every instanceruns on every instanceone worker picks each job
Dashboard / visibilitynonenonebuilt-in web dashboard
CostfreefreeOSS core; Pro license for some

BackgroundService is not an alternative to IHostedService; it is an abstract class that implements it. So the real choice is two-way: in-process hosted service (in one of its two forms) versus an external durable job system. Let me take them in order.

IHostedService: the raw lifecycle contract

IHostedService is the low-level interface the .NET generic host calls during startup and shutdown. It has exactly two methods:

// .NET 11, C# 14
public interface IHostedService
{
    Task StartAsync(CancellationToken cancellationToken);
    Task StopAsync(CancellationToken cancellationToken);
}

The host awaits every registered service’s StartAsync in registration order before it serves the first request, and it awaits StopAsync (up to HostOptions.ShutdownTimeout, 30 seconds by default) before the process exits. That ordering guarantee is the reason to use the raw interface: it is the right place for work that must complete before traffic arrives — warming a cache, running a one-time migration check, opening a long-lived connection.

// .NET 11, C# 14
public sealed class CacheWarmer(IMemoryCache cache, IProductRepository repo) : IHostedService
{
    public async Task StartAsync(CancellationToken ct)
    {
        // Runs to completion BEFORE the app starts serving requests.
        var hot = await repo.GetHotProductsAsync(ct);
        cache.Set("hot-products", hot);
    }

    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

The trap with raw IHostedService is doing long-running work inside StartAsync. If you start an infinite loop there and await it, the host never finishes starting. You have to fire the loop without awaiting it and track the Task yourself, then cancel and await it in StopAsync. That bookkeeping is exactly what BackgroundService exists to remove.

If you need even finer control — a hook that runs after every hosted service has started, or just before shutdown begins — .NET 8 added IHostedLifecycleService, which extends IHostedService with StartingAsync/StartedAsync and StoppingAsync/StoppedAsync. It is still current in .NET 11 and is the documented place for cross-service “everything is up now” validation, as Steve Gordon’s walkthrough of the interface describes.

BackgroundService: the loop you actually want

BackgroundService is the abstract base class that implements IHostedService for you using the template-method pattern. You override one method:

// .NET 11, C# 14
public sealed class QueuePump(IServiceScopeFactory scopeFactory, ILogger<QueuePump> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await using var scope = scopeFactory.CreateAsyncScope();
                var processor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();
                await processor.DrainOnceAsync(stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                break; // normal shutdown
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Order pump iteration failed; retrying");
            }

            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}

The framework calls ExecuteAsync from inside its own StartAsync, signals the stoppingToken when the host stops, and awaits your returned Task during shutdown. Two details bite people often enough to call out:

Register either form the same way:

// .NET 11, C# 14 -- Program.cs
builder.Services.AddHostedService<QueuePump>();      // BackgroundService
builder.Services.AddHostedService<CacheWarmer>();    // raw IHostedService

A BackgroundService paired with a bounded System.Threading.Channel is the canonical in-process job queue: producers write work items, the service drains them. If you have ever reached for Task.Run from a controller, that is the pattern you actually wanted — see running fire-and-forget work safely with a BackgroundService and the broader case for Channels over BlockingCollection.

When to pick the in-process options

Pick BackgroundService when:

Pick raw IHostedService (or IHostedLifecycleService) when:

Both run on every instance of your app. If you scale to three replicas, your BackgroundService runs three times, in parallel, with no coordination. For a stateless poller that is fine. For “send the nightly invoice email once,” it is a bug.

When to pick Hangfire

Pick Hangfire when any of these is true:

Minimal setup in .NET 11:

// .NET 11, C# 14 -- Program.cs
builder.Services.AddHangfire(cfg => cfg
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseSqlServerStorage(builder.Configuration.GetConnectionString("HangfireDb")));

builder.Services.AddHangfireServer();

var app = builder.Build();
app.UseHangfireDashboard("/jobs");  // lock this down in production

// Fire-and-forget, durable:
BackgroundJob.Enqueue<IInvoiceService>(s => s.SendAsync(orderId, CancellationToken.None));

// Recurring (cron):
RecurringJob.AddOrUpdate<IReportService>(
    "nightly-report",
    s => s.BuildAsync(CancellationToken.None),
    Cron.Daily(2));

Note what just changed: you now own a database table set Hangfire manages, a connection string, migrations of that schema across Hangfire upgrades, and a dashboard endpoint you must authorize. That is real operational weight. You take it on deliberately, in exchange for durability and scheduling you would otherwise hand-roll badly.

The throughput picture, with real numbers

Performance is rarely the deciding axis here, but it is worth being honest about the cost of durability. An in-process BackgroundService draining a Channel does no I/O per item beyond your own work; the dispatch overhead is effectively a method call and is not measurable against the work itself. Hangfire, by contrast, does at least one storage round-trip to dequeue and one to mark completion per job.

Hangfire’s own documentation quantifies the storage choice: switching from SQL Server to Redis yields more than 4x throughput on empty jobs, per the Using Redis guide. The absolute numbers depend on your storage latency, but the shape is fixed: Hangfire’s floor is “round-trips to a database,” and an in-process queue’s floor is “nothing.” If you are processing tens of thousands of trivial items per second, that gap matters and an in-process Channel wins outright. If you are processing thousands of jobs per minute that each do real work (call an API, render a PDF), the per-job storage cost disappears into the noise and durability is free in practice.

The rule that falls out: do not put high-frequency, loss-tolerant work through Hangfire just because it is there. A poller checking a queue every second is a BackgroundService, not 86,400 Hangfire jobs a day.

The gotcha that picks for you

Two requirements end the debate before preference enters:

  1. “This must not be lost if the app restarts.” If a job is dropped on deploy and that is a real bug — a payment capture, a confirmation email, a webhook delivery — you need durable storage, and that means Hangfire (or a real message broker). No amount of StopAsync draining makes a BackgroundService survive kill -9 or a node failure. The in-process options keep work in memory; memory dies with the process.

  2. “This must run exactly once across my replicas.” A BackgroundService runs on every instance. If you scale out and the job is not idempotent, you get duplicate work. Hangfire’s shared-storage worker model gives you single execution for free. The in-process equivalent is a distributed lock you have to build and get right.

If neither requirement applies — the work is in-process, loss-tolerant, and either runs once because you run one instance or is naturally idempotent — then adding Hangfire is paying a database tax for nothing. Use BackgroundService.

A common and correct hybrid: keep the durable schedule and retry in Hangfire, but let the recurring job’s body simply enqueue into an in-process Channel that a BackgroundService drains. Hangfire guarantees the job fires once and survives restarts; the Channel gives you fast, backpressure-aware in-process throughput. You get both properties without forcing every item through storage.

The recommendation, restated

Default to BackgroundService for anything that loops in-process. Reach for raw IHostedService or IHostedLifecycleService only when you specifically need startup ordering or pre/post-shutdown hooks. Adopt Hangfire the moment a job must survive a restart, run on a schedule, retry automatically, or execute exactly once across multiple instances — and accept the database it brings as the price of those guarantees. The instinct to reach for Hangfire “to be safe” is usually backwards: start in-process, and let a concrete durability or scheduling requirement pull you toward the heavier tool. When you are running on the built-in primitives, monitor those background jobs with health checks and metrics so you are not flying blind, and make sure your loops cancel cleanly without deadlocking on shutdown.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back