Using Hangfire with ASP.NET Core

Almost every ASP.NET Core application ends up needing some work done outside the request pipeline. Sending a batch of emails, cleaning up old records, generating a report on a schedule, these tasks should not block a user’s HTTP request. Hangfire is one of the most widely used libraries in the .NET ecosystem to solve exactly this problem, and this article walks through setting it up with SQL Server as the storage backend.

Why Hangfire and what it actually does

Hangfire lets you run two kinds of work outside the normal request flow: recurring jobs that fire on a schedule using cron expressions, and background jobs that get enqueued once and picked up as soon as a worker is free. It persists job state in a database, SQL Server in this case, so jobs survive an application restart or a deployment. It also ships with a dashboard where you can see queued, running, succeeded, and failed jobs, which is genuinely useful when something goes wrong at 2 AM.

The setup below is intentionally minimal. It covers the pieces you need to get recurring and background jobs running, plus the dashboard, plus a note on how to clean up recurring job registrations when you redeploy. Nothing more, because Hangfire itself gets complicated quickly once you start layering dependency injection, retries and multi-server setups on top.

Adding the NuGet packages

Two packages cover the core scenario: the ASP.NET Core integration and the SQL Server storage provider.

  • Hangfire.AspNetCore
  • Hangfire.SqlServer

Hangfire.AspNetCore wires the dashboard middleware and the hosted service that runs the job server inside your web application process. Hangfire.SqlServer gives you the storage provider that persists jobs, retries and job history in SQL Server tables. If you are on a different database, Hangfire also has providers for PostgreSQL and a few others, but SQL Server remains the most mature and battle tested option.

Configuring Hangfire in Program.cs

Hangfire needs to be registered as a service and then added to the middleware pipeline for the dashboard to work. The configuration below sets the data compatibility level, the serializer settings, and a few storage options that matter once you move past a toy example.

public static void Main(string[] args)
{
	var builder = WebApplication.CreateBuilder(args);
	var services = builder.Services;
	var configuration = builder.Configuration;
	var env = builder.Environment;
 
	services.AddHangfire(hangfire =>
	{
		hangfire.SetDataCompatibilityLevel(CompatibilityLevel.Version_170);
		hangfire.UseSimpleAssemblyNameTypeSerializer();
		hangfire.UseRecommendedSerializerSettings();
		hangfire.UseColouredConsoleLogProvider();
		hangfire.UseSqlServerStorage(
					configuration.GetConnectionString("HangfireConn"),
			new SqlServerStorageOptions
			{
				CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
				SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
				QueuePollInterval = TimeSpan.Zero,
				UseRecommendedIsolationLevel = true,
				DisableGlobalLocks = true
			});
 
		var server = new BackgroundJobServer(new BackgroundJobServerOptions
		{
			ServerName = "hangfire-test",
		});
	});
	
	// other services...
 
	var app = builder.Build();
 
 
	app.UseHangfireDashboard();
 
	// more middleware...
 
	app.Run();
}

A few of these options are worth calling out specifically. QueuePollInterval set to TimeSpan.Zero tells Hangfire to use a push based mechanism instead of polling the database on an interval, which reduces load on SQL Server considerably at scale. DisableGlobalLocks avoids a known contention issue when multiple servers write recurring job schedules at the same time. The connection string named HangfireConn is read from app settings, and in development this typically points at a local SQL Server or LocalDB instance.

Notice that app.UseHangfireDashboard() is called with no arguments here. That is fine for a local demo, but in a real deployment this line needs an authorization filter passed in, otherwise anyone who can reach the URL can see and control every job in your system. This is one of the most common mistakes teams make when they move a Hangfire demo into production.

Setting up the SQL Server storage

Hangfire needs its own set of tables in SQL Server to track jobs, servers, queues and history. The official Hangfire documentation covers the install script in detail, and the same script is also included in the sample repository as hangfire-default-install.sql so you do not have to hunt for it separately. Run this script once against the database referenced by your HangfireConn connection string before the application starts creating jobs.

Creating a recurring job

A recurring job in Hangfire is just a class with a method that gets invoked on a schedule. The important constraint is that this method must be reentrant, meaning it can be called again safely if a previous run failed partway through, and it needs to handle its own exceptions rather than letting them bubble up unhandled.

public class MyRecurringJob : IMyRecurringJob
{
    public void DoSomethingReentrant()
    {
        Console.WriteLine("IMyRecurringJob doing something");
    }
}

This class does nothing more than write to the console, but in a real job it would be where you put your database cleanup logic, your report generation, or whatever recurring task you need. Once the class exists, you register it with Hangfire using RecurringJob.AddOrUpdate, passing an expression that points at the method and a cron expression for the schedule.

RecurringJob.AddOrUpdate<IMyRecurringJob>(
   job => job.DoSomethingReentrant(), Cron.Hourly);

Cron.Hourly is a convenience helper, Hangfire also accepts a raw cron string if you need a custom schedule. Calling AddOrUpdate again with the same job identifier updates the existing schedule instead of creating a duplicate, which matters once you start redeploying the application regularly.

Creating a background job

A background job is a one off unit of work that gets enqueued and executed as soon as a free worker picks it up, rather than running on a schedule. You create one with BackgroundJob.Enqueue.

BackgroundJob.Enqueue<IMyBackgroundJob>(x => x.DoSomethingReentrant());

This is the pattern you would use for something triggered by user action, for example queuing an email after a signup, without making the user wait for the email to actually send. The same reentrancy rule applies here too, since Hangfire will retry a failed job by default.

Deleting recurring jobs when you redeploy

This is a detail that catches teams out in practice. If your deployment process spins up a new instance of the application and that instance calls RecurringJob.AddOrUpdate again on startup, you can end up with duplicate or orphaned recurring job entries in the storage, especially if the job identifiers or server names change between deployments. The fix is to clear out existing recurring jobs before re-registering them.

using (var connection = JobStorage.Current.GetConnection())
{
	foreach (var recurringJob in connection.GetRecurringJobs())
	{
		RecurringJob.RemoveIfExists(recurringJob.Id);
	}
}

This loop reads every recurring job currently stored and removes it before the application re-adds its own schedules. It is a blunt approach, deleting everything rather than diffing against what should exist, but for a single application owning its own Hangfire storage it works reliably. If multiple applications share the same Hangfire database, you would want to scope this more carefully by job id prefix or a separate storage instance per application.

Running the application and the dashboard

Once the application starts, Hangfire begins picking up recurring and background jobs according to their schedules, and you can create or remove jobs from application code as shown above.

The application creating and managing Hangfire jobs at runtime
The application creating and managing Hangfire jobs at runtime

The dashboard is where Hangfire earns its keep operationally. It shows succeeded, failed, processing and scheduled jobs, along with which server picked up each one, and lets you trigger a recurring job manually or requeue a failed one without touching code.

Hangfire dashboard showing job history and server status
Hangfire dashboard showing job history and server status

Production considerations before you rely on this

The setup covered here is deliberately basic, and that is by design. Hangfire can be wired into a solution in many different ways, but the KISS principle should guide how far you take it. Event driven background processing is already a source of complexity in most systems, and it needs its own monitoring and debugging habits once things go wrong in production.

A few things you should not skip before shipping this to production: secure the dashboard behind an authorization filter, be careful about injecting scoped services into job classes since the job execution context does not follow the same lifetime as an HTTP request, add proper logging around job execution, and make sure every job handler is genuinely reentrant since Hangfire will retry jobs that throw.

Hangfire versus other options

Hangfire is a solid choice, but it is not the only one, and it is worth knowing when to reach for something else. For long running workflows that need durable state across steps, something like Azure Durable Functions or a workflow engine fits better than Hangfire, which is built around discrete jobs rather than multi step processes with persisted intermediate state. For very simple periodic work inside a single application, a hosted BackgroundService with a timer is often enough and avoids pulling in a database dependency at all. ASP.NET Core also works fine with Quartz.NET if you need more advanced scheduling semantics than cron expressions give you.

None of these are strictly better than the others, they solve slightly different shapes of problem. Pick based on whether you need a dashboard, whether you already have SQL Server in your stack, and how complex your actual scheduling and retry requirements are.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading