Steven's Knowledge

Background Jobs

Running work outside the request in .NET -- in-process BackgroundService, durable jobs with Hangfire, scheduling with Quartz.NET, and when to use a queue instead

Background Jobs

Plenty of work has no business happening inside an HTTP request. Sending a welcome email, generating a report, running nightly cleanup, processing an uploaded file -- the user does not need to wait for any of it, and tying it to the request lifetime makes the request slow, fragile, and liable to fail for reasons that have nothing to do with the user's intent. The job belongs somewhere else: kicked off by the request, run on its own time.

The .NET options for that form a ladder, from "simplest, in-process, not durable" to "durable, distributed, observable." At the bottom sits an in-process hosted service with zero dependencies; in the middle, Hangfire, which persists jobs to a store and survives restarts; near the top, Quartz.NET for heavyweight scheduling, and beyond that a real message broker for inter-service work. The single question that picks your rung is this: must the job survive a process restart? If yes, you need persistence -- Hangfire or Quartz backed by a store, or a queue. If no, an in-process hosted service is enough and you should not pay for anything heavier.

In-Process: BackgroundService

BackgroundService is the built-in, zero-dependency option, and the ASP.NET Core page covers its hosting mechanics in detail, so this is a recap. You override ExecuteAsync, the host starts it at application startup and signals shutdown through the stoppingToken, and you get a long-lived loop that runs alongside your web stack. It is a good fit for periodic in-memory work and for consuming an in-process queue.

public class CleanupWorker(IServiceProvider services) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            using var scope = services.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            // ... scoped work
        }
    }
}

PeriodicTimer drives the loop cleanly, and the scope is the detail that matters: hosted services are resolved as singletons, so you cannot inject a scoped DbContext directly without creating a captive dependency that lives for the whole process. Create a scope per iteration and resolve scoped services from it, exactly as a web request would.

Then be honest about the hard limits, because they are the reason the rest of this page exists. A BackgroundService queue lives in memory, so anything in flight is lost on restart or deploy, and work in progress does not survive a crash. There is no retry, no visibility, no scheduling beyond a timer you write by hand. And it does not distribute -- every replica of your app runs the same timer, so a three-instance deployment runs your "nightly" cleanup three times. That last one is a genuine duplicate-work trap, and it is the single most common way an in-process job goes wrong in production.

Durable Jobs with Hangfire

Hangfire is the dominant background-job framework in the .NET world, and it exists precisely to fix the limits above. Jobs are persisted to a store -- SQL Server, PostgreSQL, or Redis -- so they survive restarts and deploys, they retry automatically when they throw, and the whole thing ships with a web dashboard that shows you what ran, what failed, and what is queued. You enqueue from your app code and a Hangfire server picks the job up and runs it.

builder.Services.AddHangfire(c => c.UsePostgreSqlStorage(connString));
builder.Services.AddHangfireServer();

// enqueue
BackgroundJob.Enqueue<IReportService>(s => s.GenerateAsync(reportId, CancellationToken.None));
RecurringJob.AddOrUpdate<ICleanupService>("nightly", s => s.RunAsync(), Cron.Daily);

The model is worth understanding because it explains every constraint that follows. When you call BackgroundJob.Enqueue, Hangfire does not run anything -- it serializes the method call (the target type, the method, and the arguments) into the store as a row. A Hangfire server, running either inside the same process or in a separate worker deployment, polls the store, deserializes the job, resolves the target from DI, and executes it. Success, failure, retries, and the full history all live in the store and show up in the dashboard. The enqueueing app and the executing server are decoupled through the store, which is exactly why jobs survive a restart of either side.

There are four job types, and most applications use the first three constantly:

  • Fire-and-forget: BackgroundJob.Enqueue(() => svc.Send(id)) -- runs once, as soon as a worker is free.
  • Delayed: BackgroundJob.Schedule(() => svc.Send(id), TimeSpan.FromHours(24)) -- runs once, after the delay.
  • Recurring: RecurringJob.AddOrUpdate("welcome", () => svc.Run(), Cron.Daily) -- runs on a cron schedule, identified by a stable id so re-registering updates rather than duplicates it.
  • Continuations: BackgroundJob.ContinueJobWith(parentId, () => svc.Notify(id)) -- runs only after a parent job completes, for chaining steps.

Because the job is a serialized method invocation, arguments must be serializable, and you should keep the payload small. Enqueue an id, not a fully-loaded entity graph: a big object bloats the store, serializes slowly, and -- worse -- captures a stale snapshot of data that may have changed by the time the job runs minutes or hours later. Pass the identifier and load the current data inside the job.

And the idempotency point that haunts every durable system applies here too: Hangfire gives you at-least-once execution. A job that throws is retried, and a job that fails after doing half its work will run again from the top, so the work must be safe to repeat. Sending an email twice is annoying; charging a card twice is a bug report. Make jobs idempotent -- check whether the effect has already happened, or key the side effect on the job so a repeat is a no-op.

Scheduling with Quartz.NET

When the requirement is rich scheduling rather than a queue of one-off jobs -- complex cron expressions, business calendars, misfire handling when the scheduler was down at fire time, and clustering across nodes -- Quartz.NET is the heavyweight scheduler, a mature port of Java's Quartz. You define an IJob, and you attach one or more triggers that decide when it fires.

public class ReportJob : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        // ... do the scheduled work
    }
}

builder.Services.AddQuartz(q =>
{
    var key = new JobKey("report");
    q.AddJob<ReportJob>(key);
    q.AddTrigger(t => t
        .ForJob(key)
        .WithCronSchedule("0 0 2 * * ?")); // 02:00 daily
});
builder.Services.AddQuartzHostedService();

The contrast with Hangfire is the useful thing to hold onto. Hangfire is job-processing-first: a queue of work items, with scheduling bolted on and a genuinely great dashboard. Quartz is scheduling-first: fine-grained triggers, calendars, and clustering, with much less of a story around an ad-hoc queue of fire-and-forget jobs. For most applications -- where you mostly enqueue one-off and delayed work and want retries and visibility -- reach for Hangfire. Choose Quartz when scheduling complexity is the core requirement and you need triggers, misfire policies, and calendars that Hangfire's Cron helpers cannot express.

Jobs vs Queues vs Hosted Services -- Choosing

The three tools solve overlapping problems, and the mistake is reaching past the simplest one that fits. A crisp way to choose:

NeedReach for
Ephemeral periodic work, no durability, single ownerIn-process BackgroundService
Durable one-off / delayed / recurring jobs, retries, a dashboard, moderate scaleHangfire
High throughput, cross-service decoupling, event-driven fan-out, back-pressureA real message broker (RabbitMQ / Azure Service Bus via MassTransit)

The boundary that matters most is the one between Hangfire and a broker. Hangfire is excellent for your application's own background chores -- the report this service generates, the email this service sends, the cleanup this service runs. It is not a substitute for a message broker in a distributed, event-driven architecture. The moment the work is really inter-service messaging -- one service publishing an event that several others react to, with the throughput, routing, and back-pressure that implies -- you want a broker and a messaging library, not a job framework pointed at a shared database. Using Hangfire's store as a pseudo-broker between services works until the volume or the coupling makes it not work, and by then it is load-bearing.

Distributed Execution and Reliability

The durable frameworks earn their keep at scale because they coordinate workers through the shared store. A Hangfire recurring job or a clustered Quartz trigger fires once across the whole cluster, not once per instance -- which directly solves the BackgroundService duplicate-work problem, since the store is the single source of truth about whether this occurrence has already been claimed. You can run multiple servers against the same store and they cooperatively dequeue, so you scale workers horizontally by deploying more of them rather than rewriting anything.

Reliability follows from the same persistence. A job that exhausts its retries does not vanish -- it lands in a failed state in the store, visible in the dashboard, where you can inspect the exception and requeue it once the underlying cause is fixed. That is the difference in kind from an in-process service, where a crashed job leaves no trace and no recovery path: the work is simply gone, and you find out from the absence of its effect.

Sharp Edges

The recurring failures are predictable. In-process BackgroundService jobs are lost on every deploy and duplicated across replicas -- the two failures that push teams to Hangfire in the first place. With Hangfire, serializing large arguments or non-serializable types into the store bloats it and fails at enqueue time; keep the payload to an id and load data inside the job, which also avoids acting on a stale snapshot. Non-idempotent jobs double-run on retry, because execution is at-least-once -- design every job to be safe to repeat. Watch the clock: Hangfire's Cron helpers are UTC by default, so a "midnight" job that you reasoned about in local time fires at the wrong hour, and daylight-saving transitions add their own surprises. Long-running jobs block workers -- a server has a finite worker count, and a handful of slow jobs can starve everything behind them, so size the worker pool to the workload. And the dashboard is powerful enough to be dangerous: it can trigger and requeue jobs, so secure the Hangfire dashboard behind authentication -- an exposed dashboard is remote code execution dressed as an admin page.

On this page