Hangfire — Fire-and-Forget, Delayed & Recurring Jobs With a UI
Enqueue background jobs, schedule recurring work, wire up continuations, and manage everything from the built-in Hangfire dashboard — with SQL Server or Redis storage.
Why Hangfire?
Hangfire persists jobs to a database before executing them — if your app crashes mid-execution, the job retries on restart. It ships with a dashboard UI that shows queues, running jobs, failed jobs, and retry history. For most .NET APIs that need background work without the overhead of a full message broker, Hangfire is the right call.
Install
dotnet add package Hangfire.AspNetCore
# Choose one storage backend:
dotnet add package Hangfire.SqlServer
dotnet add package Hangfire.Pro.Redis # or: dotnet add package Hangfire.Redis.StackExchange (community)Setup
// Program.cs
builder.Services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire"), new SqlServerStorageOptions
{
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
QueuePollInterval = TimeSpan.Zero, // use SQL notifications instead of polling
UseRecommendedIsolationLevel = true,
DisableGlobalLocks = true
})
);
builder.Services.AddHangfireServer(options =>
{
options.WorkerCount = Environment.ProcessorCount * 2;
options.Queues = ["critical", "default", "low"];
});
var app = builder.Build();
app.UseHangfireDashboard("/hangfire");Hangfire creates its schema automatically on first run.
Fire-and-Forget
Enqueue work that should happen as soon as possible, outside the request:
[ApiController, Route("api/orders")]
public class OrdersController(IBackgroundJobClient jobs) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
var order = await _orderService.CreateAsync(request);
// Don't make the user wait for email delivery
jobs.Enqueue<IEmailService>(
s => s.SendOrderConfirmationAsync(order.Id, CancellationToken.None));
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
}The lambda is serialized as JSON and persisted. If the process dies before the email sends, Hangfire retries it on next startup.
Delayed Jobs
Schedule a job to run after a specific delay:
// Send a follow-up email 3 days after signup
jobs.Schedule<IEmailService>(
s => s.SendFollowUpAsync(userId, CancellationToken.None),
TimeSpan.FromDays(3));
// Specific point in time
jobs.Schedule<IReportService>(
s => s.GenerateMonthlyReportAsync(month, CancellationToken.None),
new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month + 1, 1, 0, 0, 0, TimeSpan.Zero));Recurring Jobs
// In Program.cs after app.Build() — or in an IHostedService.StartAsync
RecurringJob.AddOrUpdate<IInvoiceService>(
recurringJobId: "monthly-invoices",
methodCall: s => s.GenerateAllAsync(CancellationToken.None),
cronExpression: Cron.Monthly(1, 2) // 1st of month at 02:00
);
RecurringJob.AddOrUpdate<ICacheService>(
recurringJobId: "cache-warmup",
methodCall: s => s.WarmupAsync(CancellationToken.None),
cronExpression: "*/15 * * * *" // every 15 minutes
);Use RecurringJob.AddOrUpdate — it's idempotent. Safe to call on every startup.
Continuations
Chain jobs so the second starts only when the first completes successfully:
var processJobId = jobs.Enqueue<IPaymentService>(
s => s.ProcessPaymentAsync(orderId, CancellationToken.None));
jobs.ContinueJobWith<IFulfillmentService>(
processJobId,
s => s.FulfillOrderAsync(orderId, CancellationToken.None));
jobs.ContinueJobWith<IEmailService>(
processJobId,
s => s.SendReceiptAsync(orderId, CancellationToken.None));Both continuations trigger when processJobId succeeds. Build pipelines by chaining continuations.
DI in Job Classes
Hangfire resolves job classes from DI automatically when you use AddHangfire with AspNetCore:
public class InvoiceService(
AppDbContext db,
IPdfGenerator pdf,
IEmailSender email,
ILogger<InvoiceService> logger) : IInvoiceService
{
public async Task GenerateAllAsync(CancellationToken ct)
{
var customers = await db.Customers
.Where(c => c.BillingEnabled)
.ToListAsync(ct);
foreach (var customer in customers)
{
var invoice = await pdf.GenerateInvoiceAsync(customer, ct);
await email.SendInvoiceAsync(customer.Email, invoice, ct);
logger.LogInformation("Invoice sent to {Email}", customer.Email);
}
}
}No special attributes or interfaces required — just register InvoiceService in DI and Hangfire finds it.
Queues
Route jobs to specific queues for priority control:
// Enqueue to a specific queue
jobs.Enqueue<IEmailService>(
"[critical]",
s => s.SendPasswordResetAsync(userId, CancellationToken.None));
// Or with attribute on the job class
[Queue("low")]
public class ReportArchiveJob : IJob { ... }Configure server workers per queue:
builder.Services.AddHangfireServer(options =>
{
options.WorkerCount = 20;
options.Queues = ["critical", "default", "low"];
// Workers process queues in order — critical first
});Retries and Failure Handling
// Global retry policy — 10 automatic retries with exponential back-off
GlobalJobFilters.Filters.Add(new AutomaticRetryAttribute { Attempts = 10 });
// Per-job override
public class PaymentService : IPaymentService
{
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [60, 300, 900])]
public async Task ProcessPaymentAsync(Guid orderId, CancellationToken ct)
{
// ...
}
}After all retries are exhausted, the job moves to the Failed queue in the dashboard where you can inspect the exception and retry manually.
Securing the Dashboard
// Simple: restrict to localhost in development
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = [new LocalRequestsOnlyAuthorizationFilter()]
});
// Production: require an authenticated admin role
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = [new HangfireAuthorizationFilter()]
});
public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
return httpContext.User.Identity?.IsAuthenticated == true
&& httpContext.User.IsInRole("Admin");
}
}Redis Storage
For high-throughput scenarios, Redis storage has lower latency than SQL:
builder.Services.AddHangfire(config => config
.UseRedisStorage(builder.Configuration.GetConnectionString("Redis"), new RedisStorageOptions
{
Prefix = "hangfire:",
InvisibilityTimeout = TimeSpan.FromMinutes(30)
})
);Redis doesn't persist to disk by default — configure appendonly yes in redis.conf or use Redis persistence to avoid losing queued jobs on restart.
Summary
BackgroundJob.Enqueuefor fire-and-forget;Schedulefor delayed;RecurringJob.AddOrUpdatefor cron- Continuations let you build job pipelines without a message broker
- Dashboard at
/hangfiregives real-time visibility into queues, running jobs, and failures - Restrict the dashboard with
IDashboardAuthorizationFilter— it's unauthenticated by default - SQL Server for simplicity; Redis for high throughput — both survive process restarts
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.