.NET & C# Development · Lesson 76 of 92

Hangfire — Retry Failed Jobs & Watch Them on a Dashboard

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

Bash
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

C#
// 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:

C#
[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:

C#
// 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

C#
// 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:

C#
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:

C#
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:

C#
// 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:

C#
builder.Services.AddHangfireServer(options =>
{
    options.WorkerCount = 20;
    options.Queues = ["critical", "default", "low"];
    // Workers process queues in order — critical first
});

Retries and Failure Handling

C#
// 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

C#
// 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:

C#
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.Enqueue for fire-and-forget; Schedule for delayed; RecurringJob.AddOrUpdate for cron
  • Continuations let you build job pipelines without a message broker
  • Dashboard at /hangfire gives 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