Remigiusz ZalewskiRemigiusz Zalewski

Using Hangfire to manage the jobs in .NET

hangfire-background-jobs-dotnet

Introduction

You want to send a welcome email after a user signs up, but you do not want the HTTP request to wait for the SMTP server. So you kick it off with Task.Run and return immediately. It works in the demo. Then the app pool recycles mid-send, or the server restarts during a deploy, and that email is gone with no trace and no retry.

Hangfire fixes the whole class of problem. It persists every job to storage - SQL Server in this video - before it runs, retries failures automatically, and gives you a dashboard to see what ran, what is queued, and what is failing. In this build you set up four job types (fire-and-forget, delayed, continuation, and recurring), split the app into a producer that enqueues work and a separate server that processes it, and secure the dashboard with basic authentication.

🎬 Watch the full video here:


The core idea: jobs are persisted before they run

The reason Hangfire is more than a nicer Task.Run is storage. When you enqueue a job, Hangfire serializes the method call and its arguments into a database table first. A worker then picks it up and invokes it. If the process dies before the job finishes, the record is still there, and another worker (or the same one after restart) picks it back up. Failures are retried on a backoff schedule automatically, and the full history stays queryable.

That is why the method you enqueue is expressed as an expression, not a delegate: x => x.Execute(). Hangfire needs to inspect and store the call, then reconstruct it later by resolving the type from DI.

Configuring storage

Both the producer and the server register Hangfire against the same SQL Server connection string:

builder.Services.AddHangfire(opt =>
{
    opt.UseSqlServerStorage(connectionString)
        .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
        .UseSimpleAssemblyNameTypeSerializer()
        .UseRecommendedSerializerSettings();
});

On first run Hangfire creates its own schema (tables like Job, State, Server, Hash) in that database. UseRecommendedSerializerSettings() is not optional boilerplate - it sets up the serializer the way current Hangfire expects, and skipping it causes subtle argument-deserialization bugs later.

Producer and server as separate processes

The demo deliberately splits responsibilities:

  • The producer is a web API. It adds Hangfire storage and the dashboard, but does not call AddHangfireServer(). It only enqueues jobs.
  • The server is a separate host that calls AddHangfireServer() and registers the job implementations and their dependencies. It is the process that actually pulls jobs off the queue and runs them.

Both point at the same database, and that shared database is the entire communication channel between them. This is the model you want in production: your API stays responsive and lightweight, and you can scale the job-processing tier independently - run three server instances and they cooperatively drain the same queue without extra configuration.

For a smaller app it is perfectly fine to call AddHangfireServer() inside the API itself and run everything in one process. The split is about scaling and isolation, not correctness.

The four job types

Fire-and-forget runs once, as soon as a worker is free:

_backgroundJobClient.Enqueue(() => Console.WriteLine(text));

Delayed runs once after a delay:

_backgroundJobClient.Schedule(() => Console.WriteLine(text), TimeSpan.FromMinutes(1));

Continuation runs only after a parent job finishes successfully:

var jobId = _backgroundJobClient.Schedule(() => Console.WriteLine(text), TimeSpan.FromMinutes(1));
_backgroundJobClient.ContinueJobWith(jobId, () => Console.WriteLine(text));

Recurring runs on a cron schedule and is registered once, by ID, at startup:

RecurringJob.AddOrUpdate<ISendEmailJob>(
    jobId, x => x.Execute(), Cron.Minutely);

AddOrUpdate is idempotent - calling it again with the same ID updates the schedule in place rather than creating a duplicate. Note that Hangfire's recurring jobs use standard five-field cron, unlike Quartz.

Jobs are just DI services

The recurring email job in the video is an interface plus an implementation registered as scoped:

services.AddScoped<ISendEmailJob, SendEmailJob>();
services.AddScoped<IEmailService, EmailService>();

When Hangfire runs x => x.Execute(), it resolves ISendEmailJob from the container inside a fresh scope, so the job can take constructor dependencies like IEmailService and IOptions<T> for the SMTP credentials. Keep job classes thin: they should translate a queued call into a single call on a real service, and nothing more.

Securing the dashboard

UseHangfireDashboard("/hangfire") exposes a full monitoring UI - queued jobs, processing, succeeded, failed, retries, recurring schedules, and the ability to trigger or delete jobs by hand. That last part is exactly why it cannot be left open. The demo wraps it with a basic-authentication filter reading a username and password from configuration:

app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    Authorization = new[] { new HangfireCustomBasicAuthenticationFilter { /* user, pass from config */ } }
});

Basic auth over HTTPS is the minimum. In a real deployment you would more likely gate it behind your existing auth using an IDashboardAuthorizationFilter that checks the current user's role.

Common pitfalls

  • Enqueuing a lambda that captures state. Hangfire stores the method call and arguments, not a closure. Anything the job needs at run time must be a serializable argument or resolved from DI inside the job.
  • No server running. If nothing calls AddHangfireServer() against that storage, jobs queue up forever and never execute. Easy to miss with the producer/server split.
  • Long-running work with no idempotency. Hangfire retries failed jobs. If your job is not safe to run twice, a retry after a partial failure will hurt. Design jobs to be idempotent.
  • Leaving the dashboard unauthenticated. By default the dashboard is only reachable from localhost, but people routinely relax that and forget to add an auth filter.

Key Takeaways

  • Hangfire persists jobs to storage (SQL Server here) before running them, so work survives restarts and failures are retried automatically.
  • Enqueue jobs as expressions (x => x.Execute()) so Hangfire can store and later reconstruct the call through DI.
  • Four job types cover most needs: fire-and-forget, delayed, continuation, and recurring (cron).
  • Split the producer (enqueues) from the server (AddHangfireServer(), processes) to scale job handling independently; both just share the same database.
  • Job classes are ordinary scoped DI services - keep them thin wrappers over real services.
  • The dashboard can trigger and delete jobs, so always put an authorization filter in front of it.

Get the Full Source Code

The complete runnable solution - the producer API, the standalone job server, the shared job contracts, the SQL Server storage wiring, the recurring email job, and the secured dashboard - is available to Patreon supporters. If you want to enqueue jobs and watch them move through the dashboard yourself instead of rebuilding the setup from the walkthrough above, you can find it on Patreon.

Resources