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
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:
Three details in that snippet are non-negotiable in production, because each one is a bug I've seen live:
- The scope factory. A
BackgroundServiceis a singleton; injectingDbContextdirectly is a captive dependency. Create a scope per iteration. - The catch-all inside the loop. Since .NET 6, an unhandled exception in
ExecuteAsyncstops the entire host by default. One bad iteration shouldn't kill your app — catch, log, continue. PeriodicTimeroverTask.Delayin 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:
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:
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
- Dependency Injection in .NET: Lifetimes and Traps — the scope-factory pattern explained in depth.
- Async/Await in C#: 8 Common Mistakes — essential reading before writing any ExecuteAsync loop.
- Consistent API Error Handling with ProblemDetails — the request-side counterpart to background resilience.
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.