EnterpriseNET
Background Jobs in ASP.NET Core Using Hangfire
By Darshil Shah · Nov 2026 · 14 min read
- Hangfire
- ASP.NET Core
- .NET
- Background Jobs
- C#
Some work should not happen while the user waits. Sending a confirmation email, creating a PDF invoice, calling a slow third-party API or cleaning up old data does not need to finish before you reply. Do it inside the request and the user waits, requests time out and one failed email can break a whole checkout.
The usual fix is `Task.Run` or an async call that is not awaited. It removes the wait, but if the app restarts or crashes, the work is simply lost. `BackgroundService` has the same limit, because its queue lives in memory.
Hangfire saves each job to a database first, then runs it. Because it is stored, the job can be retried, tracked and picked up again after a restart.
## What Hangfire gives you
Hangfire supports four kinds of jobs: fire-and-forget, delayed, recurring and continuations.
On top of the job types, Hangfire gives you:
- **Automatic retries.** A failed job is retried up to 10 times, with a longer wait before each try.
- **Persistent storage.** Every job is saved in a database before it runs.
- **A built-in dashboard.** A web page at `/hangfire` shows every job and lets you retry or delete jobs by hand.
- **Distributed processing.** Several servers can share the same job storage and split the work between them.
Here, database means any supported storage — relational, NoSQL or Redis.
## Why I pick Hangfire
- **Faster API responses.** The API saves the jobs and replies at once. The user never waits for a slow third-party service such as a payment gateway or a mail server.
- **Plain C# methods.** A job is an ordinary method on an ordinary class. There is no base class to inherit, so jobs are easy to test.
- **Dependency injection.** Hangfire creates your services through the normal ASP.NET Core container, just as it does for a controller.
- **Visibility.** The dashboard shows what ran, what failed and why. By default, it keeps successful jobs for one day.
- **Room to grow.** You can start with jobs running inside your web app and move them to separate worker servers later, without changing the job code.
Hangfire does not need a message broker, but it is not free of infrastructure. It adds a set of tables to your database, a background loop that checks them and a dashboard you must protect.
Hangfire fits work that nobody waits for, but that you cannot afford to lose.
## Where Hangfire stores your jobs
Hangfire stores its jobs in a database. The choice affects speed, cost and support, so make it before you write any code.
- **SQL Server (`Hangfire.SqlServer`):** official and free. The right choice for most .NET apps. You must also add `Microsoft.Data.SqlClient`.
- **In-memory (`Hangfire.InMemory`):** official and free. Good for local runs and tests only, because everything is lost when the app stops.
- **Redis, Pro version (`Hangfire.Pro.Redis`):** official, but paid. Built for high volume and needs a commercial licence.
- **Redis, community version (`Hangfire.Redis.StackExchange`):** community-maintained and free. Gives you Redis without a licence, but with no official support.
- **PostgreSQL (`Hangfire.PostgreSql`):** community-maintained and free. A good fit for teams already on PostgreSQL. By default it checks for new jobs only every 15 seconds, so turn on `EnableLongPolling`.
- **MySQL:** there is no actively maintained package, so choose another option.
- **MongoDB (`Hangfire.Mongo`):** community-maintained and free. A good fit for teams already on MongoDB, but with no official support.
With SQL Server, Hangfire creates its own tables the first time it runs, but it does not create the database. Create the database first or the app fails with a Cannot open database error.
## A real-world order flow
The sample is a small order API. When a customer places an order, the API saves it and replies straight away. The payment, confirmation email, invoice and review reminder all run in the background and a sales report runs every night.
### What you need
- .NET 10 SDK
- Visual Studio 2026, VS Code or JetBrains Rider
- Basic knowledge of ASP.NET Core Web API
You do not need a database to run the sample on your machine. It uses in-memory storage in Development.
### Installing the packages
```bash
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer
dotnet add package Hangfire.InMemory
dotnet add package Microsoft.Data.SqlClient
```
`Hangfire.AspNetCore` brings in the core Hangfire library. `Microsoft.Data.SqlClient` is required: `Hangfire.SqlServer` does not include a SQL client of its own and it fails at startup without one.
### Wiring Hangfire into Program.cs
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHangfire(config =>
{
config.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings();
if (builder.Configuration["Storage:Provider"] == "InMemory")
config.UseInMemoryStorage();
else
config.UseSqlServerStorage(
builder.Configuration.GetConnectionString("HangfireConnection"));
});
builder.Services.AddHangfireServer();
builder.Services.AddSingleton<IOrderStore, InMemoryOrderStore>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IPaymentService, PaymentService>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
builder.Services.AddScoped<IReportService, ReportService>();
builder.Services.AddControllers();
builder.Services.AddAntiforgery();
var app = builder.Build();
app.UseHangfireDashboard();
app.MapControllers();
app.Services.GetRequiredService<IRecurringJobManager>()
.AddOrUpdate<IReportService>("daily-sales-report",
report => report.SendDailySalesReport(CancellationToken.None),
Cron.Daily(2));
app.Run();
```
What each part does:
- `AddHangfire` sets up Hangfire and its storage. The sample uses in-memory storage when you run it locally and SQL Server everywhere else, controlled by the `Storage:Provider` setting.
- `SetDataCompatibilityLevel`, `UseSimpleAssemblyNameTypeSerializer` and `UseRecommendedSerializerSettings` are Hangfire’s recommended settings. They store jobs in the newest data format with short type names, so stored jobs still load after you release a new version of your app.
- `AddHangfireServer` starts the background workers. Every copy of the app that calls it will process jobs.
- `UseHangfireDashboard` opens the dashboard at `/hangfire`. By default only your own machine can open it; a later section shows how to secure it for production.
- `Cron.Daily(2)` means 02:00 UTC. Pass a time zone in `RecurringJobOptions` if you need local time. Hangfire replaces `CancellationToken.None` with a real token when the job runs.
### Writing the job services
A job is just a method on a normal service. Attributes go on the interface. The controller creates jobs through `IPaymentService`, so Hangfire reads the attributes from the interface. An attribute placed on the class is silently ignored.
```csharp
public interface IPaymentService
{
[AutomaticRetry(Attempts = 3)]
Task CapturePayment(int orderId);
}
public interface IEmailService
{
[AutomaticRetry(Attempts = 3)]
Task SendOrderConfirmation(int orderId);
Task SendReviewReminder(int orderId);
}
public class EmailService(IOrderStore orders, ILogger<EmailService> logger) : IEmailService
{
public Task SendOrderConfirmation(int orderId)
{
var order = orders.Get(orderId)
?? throw new InvalidOperationException(
quot;Order {orderId} not found.");logger.LogInformation("Confirmation sent for order {OrderId}", orderId);
return Task.CompletedTask;
}
public Task SendReviewReminder(int orderId) => Task.CompletedTask;
}
```
Jobs receive only the order ID. The service loads the rest of the order itself, so no personal data is stored inside the job. A missing order fails the job. With `[AutomaticRetry(Attempts = 3)]` it tries three times and then lands in Failed, where someone will see it. A job that quietly returns tells you nothing.
### Creating the jobs in an application service
The controller does one thing: it hands a validated request to the service. The job wiring lives in `OrderService`, so the order of operations — save first, then create the jobs — sits in one place.
```csharp
public class OrderService(
IBackgroundJobClient jobs, IOrderStore orders, IConfiguration config) : IOrderService
{
public PlaceOrderResult PlaceOrder(OrderRequest request)
{
var orderId = orders.Add(request);
var paymentJob = jobs.Enqueue<IPaymentService>(p => p.CapturePayment(orderId));
var emailJob = jobs.ContinueJobWith<IEmailService>(paymentJob,
e => e.SendOrderConfirmation(orderId));
jobs.ContinueJobWith<IInvoiceService>(emailJob, i => i.GenerateInvoice(orderId));
var delay = config.GetValue<TimeSpan?>("Demo:ReviewReminderDelay")
?? TimeSpan.FromDays(3);
jobs.Schedule<IEmailService>(e => e.SendReviewReminder(orderId), delay);
return new PlaceOrderResult(orderId, paymentJob, delay);
}
}
[ApiController]
[Route("api/[controller]")]
public class OrdersController(IOrderService orders) : ControllerBase
{
[HttpPost]
public IActionResult PlaceOrder([FromBody] OrderRequest request)
{
var result = orders.PlaceOrder(request);
return Accepted(new { result.OrderId });
}
}
```
Save the order before you create the jobs. A worker can start a job within a fraction of a second and the order must already exist when it does.
## The gap between saving the order and creating the job
`PlaceOrder` writes twice: once to your own database, once to Hangfire’s. Nothing joins the two. If the app crashes between them, you have an order that nobody will ever charge. Swap the lines and you get the opposite bug: a payment job for an order that does not exist.
There are two ways out:
- **Use an outbox.** Write a row describing the work inside the order’s own transaction, then let a recurring job turn outbox rows into Hangfire jobs. This works with any storage and it is the approach to use on .NET 10.
- **Add a reconciliation job.** Run it every few minutes to find orders with no payment result and enqueue the missing work. It is the least elegant option and the easiest to ship.
You may see `TransactionScope` suggested for this. It only helps with the .NET Framework build of `Hangfire.SqlServer`. The build a .NET 10 app loads has no `TransactionScope` support, so wrapping `PlaceOrder` in a scope does not make the order and its jobs commit together.
Open-source Hangfire cannot create several jobs in one atomic step. Batches, which can, is a Hangfire Pro feature. Creating each next step at the end of the previous job keeps that window small. In production, create each next step at the end of the job before it.
## Protecting the dashboard
The dashboard can retry and delete jobs, so it must not be open to the public.
By default, Hangfire lets only requests from the local machine open it. That sounds safe, but it depends on where your app runs:
- Behind a reverse proxy such as Nginx or IIS on the same machine, every request looks like it comes from the local machine. The dashboard is then open to everyone.
- In a container or behind a load balancer, requests look like they come from outside. Nobody can open the dashboard, including your own team.
So do not rely on the default in production. Require a real login instead:
```csharp
using Hangfire.Dashboard;
builder.Services.AddAntiforgery();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
DisplayStorageConnectionString = false
});
}
else
{
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
DisplayStorageConnectionString = false,
Authorization = new[] { new HangfireDashboardAuthFilter() },
IsReadOnlyFunc = context =>
!context.GetHttpContext().User.IsInRole("Admin")
});
}
public class HangfireDashboardAuthFilter : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext context)
{
var user = context.GetHttpContext().User;
return user.Identity?.IsAuthenticated == true
&& (user.IsInRole("Admin") || user.IsInRole("Support"));
}
}
```
Do not use the `Hangfire.Dashboard.Authorization` package. It targets the old .NET Framework. A .NET 10 app can still restore it, with compatibility warning NU1701, but its filters are built on OWIN, so they are the wrong model for ASP.NET Core.
If you can, keep the dashboard off the public internet entirely, for example on an internal network.
## What happens when a job fails
When a job throws an exception, Hangfire retries it automatically. By default it tries 10 more times, waiting longer before each try. Together, the 10 retries take roughly 4 to 5 hours. You can set your own limit for each job:
```csharp
public interface IPaymentService
{
[AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 5, 10, 20 })]
Task CapturePayment(int orderId);
}
```
Failed jobs are never deleted automatically. They stay in the database until someone retries or deletes them. Do not catch and hide exceptions. If a job swallows its own error, Hangfire marks it as Succeeded and nobody learns that the work failed.
## The continuation trap
A continuation runs only after its parent job succeeds. If the parent fails, the continuation waits in Awaiting forever and Hangfire does not warn you.
If the next step must run anyway, make the parent job delete itself instead of failing when its retries run out, with `OnAttemptsExceeded = AttemptsExceededAction.Delete`. Then pass `JobContinuationOptions.OnAnyFinishedState` to `ContinueJobWith`.
## Making jobs safe to run twice
Hangfire makes sure a job is not lost. The price is that a job may sometimes run more than once. Make each job idempotent: running it twice must have the same result as running it once.
```csharp
public Task SendOrderConfirmation(int orderId)
{
var order = orders.Get(orderId)
?? throw new InvalidOperationException(
quot;Order {orderId} not found.");if (orders.WasConfirmationSent(orderId))
return Task.CompletedTask;
// send the email, passing a provider key such as order-confirmation-{orderId}
orders.MarkConfirmationSent(orderId);
return Task.CompletedTask;
}
```
Do not record the send before you make it. If the send throws, the retry sees the work as already done and skips it and the customer never gets the email.
## Production details that matter
- **Changing a job method:** add the new method, keep the old one working, then remove the old method once all old jobs have run.
- **Workers:** by default each server runs five workers per CPU core, up to 20. Async does not free a worker slot while you await.
- **Queues:** give urgent and slow jobs their own workers. With SQL Server storage, queue order in the options array does not set priority.
- **Personal data:** never put email addresses, names or phone numbers into job arguments. Pass an ID and load the details inside the job.
- **Separate servers:** API servers call only `AddHangfire`. Worker servers call both `AddHangfire` and `AddHangfireServer`.
- **Shutdown:** raise `StopTimeout` and check the cancellation token inside long jobs.
## What we learned
Hangfire makes background work in ASP.NET Core simple: save a job and Hangfire runs it, retries it and shows it on a dashboard. Getting it ready for production takes a few more steps: choose supported storage, protect the dashboard, set sensible retry limits, make every job safe to run twice, pass IDs not personal data and give jobs time to stop during deployments.
If your problem is sending events between many services, a message broker such as RabbitMQ or Azure Service Bus is a better fit. For reliable background work inside a .NET application, Hangfire is a practical and dependable choice.