.NET Job Scheduling — Hangfire and Persistent Reliability
A user uploads a 200 MB video to your platform at 3:14 PM. Transcoding it into multiple formats—1080p, 720p, mobile—takes twelve minutes on average, sometimes longer. Keeping the HTTP request open that long? Unacceptable. But here’s the problem: during our Tuesday maintenance window last month, we restarted the app servers, and boom—87 video processing jobs vanished into thin air. Users got “upload successful” messages, but their videos never appeared. Not ideal when you’re charging for the service.
You need persistence: the ability to store job definitions in a database, detach them from the request lifecycle, and guarantee execution even when infrastructure hiccups.
Hangfire solves this by turning background jobs into first-class database records. When you enqueue a job, Hangfire serializes the method invocation—class name, method signature, parameters—and persists it to SQL Server, PostgreSQL, or Redis. Worker threads poll the storage, claim jobs, execute them, and record outcomes. If a worker crashes mid-execution, another worker picks up the job and retries it based on configurable policies. If the entire application restarts, queued jobs remain intact, waiting for workers to resume processing.
This architecture makes Hangfire particularly suited for web applications where background work must survive deployments, process restarts, or transient failures. The trade-off: you need a database. For teams already running SQL Server or PostgreSQL, this is minimal overhead. For environments preferring stateless components, the infrastructure requirement merits consideration.
Core Architecture: Storage, Workers, and Coordination
Hangfire’s design centers on three components: the storage backend, the job server (workers), and the client API that enqueues jobs.
Storage holds job definitions, execution history, and metadata. Hangfire serializes method calls—including parameter values—as JSON and stores them in tables like HangFire.Job, HangFire.State, and HangFire.JobQueue. When a job is enqueued, a record appears in the database. When a worker processes it, the state transitions from Enqueued to Processing to Succeeded or Failed. This persistence is what differentiates Hangfire from in-memory schedulers: jobs are durable, observable, and recoverable.
Supported storage backends include SQL Server (the default), PostgreSQL, MySQL, MongoDB, and Redis. SQL-based backends offer strong consistency and integrate seamlessly with existing relational infrastructure. Redis provides lower latency for high-throughput scenarios where job volumes exceed thousands per minute. Choosing a backend depends on your existing infrastructure and performance requirements—SQL Server for most .NET shops, Redis for systems already using it for caching or session state.
Workers execute jobs. Each Hangfire server instance starts dedicated background threads—not the ASP.NET Core thread pool—that poll the storage for Enqueued jobs. Polling uses database-specific mechanisms: SQL Server leverages UPDLOCK and READPAST hints to claim jobs atomically, ensuring only one worker processes each job even when multiple servers run concurrently. Workers fetch jobs, deserialize method calls, invoke them using reflection, and update job states in the database.
The number of worker threads is configurable. A single-instance application might run five workers; a scaled-out deployment with three servers might run fifteen total workers (five per server). More workers increase throughput but consume more database connections and CPU. Tuning depends on job execution time: CPU-bound jobs benefit from fewer workers matching CPU core counts, while I/O-bound jobs can support more workers since threads spend time waiting on external resources.
Clients enqueue jobs via a simple API. BackgroundJob.Enqueue(() => Console.WriteLine("Hello")) serializes the method call and inserts it into the database. The calling thread returns immediately; the work happens asynchronously on a worker thread. This decoupling is essential for web applications: controllers enqueue jobs in milliseconds and respond to users, while workers process jobs in the background without blocking HTTP requests.
Hangfire also supports delayed jobs (scheduled to run after a time interval), recurring jobs (executed on a cron schedule), and continuations (jobs that run after a parent job succeeds). Each pattern maps to database records with corresponding state transitions, enabling rich workflows without custom orchestration code.
Configuration and Integration
Integrating Hangfire into an ASP.NET Core application requires three steps: configuring storage, starting the server, and optionally enabling the dashboard.
First, install the NuGet package. For SQL Server:
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer
Second, configure storage and start the server in Program.cs:
builder.Services.AddHangfire(configuration => configuration
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage("Server=.;Database=HangfireDB;Integrated Security=True;"));
builder.Services.AddHangfireServer();
var app = builder.Build();
app.UseHangfireDashboard();
app.Run();
This configuration connects to a SQL Server database, starts worker threads, and exposes the dashboard at /hangfire. The dashboard provides real-time visibility into job states: succeeded, failed, processing, scheduled, and enqueued. You can manually trigger recurring jobs, delete failed jobs, or re-enqueue them for retry.
Third, enqueue jobs from anywhere in your application—controllers, services, background tasks:
public class OrderController : ControllerBase
{
[HttpPost]
public IActionResult ProcessOrder(Order order)
{
BackgroundJob.Enqueue<IOrderProcessor>(x => x.ProcessAsync(order.Id));
return Accepted();
}
}
The controller responds immediately with 202 Accepted. The ProcessAsync method executes asynchronously on a worker thread. If processing fails—database timeout, external API unavailable—Hangfire automatically retries it up to ten times with exponential backoff (configurable). Failed jobs appear in the dashboard with full stack traces, enabling debugging without log archaeology.
Recurring jobs use cron expressions:
RecurringJob.AddOrUpdate("nightly-report",
() => GenerateReport(),
Cron.Daily(2)); // 2 AM daily
Hangfire stores the recurring job definition in the database and triggers it based on the cron schedule. If the application is down during the scheduled time, Hangfire executes the job as soon as a server starts. This “catch-up” behavior prevents missed executions but can cause bursts if the application was offline for extended periods.
Retry Policies and Error Handling
Transient failures—network timeouts, temporary database unavailability—shouldn’t cause permanent job failures. Hangfire’s automatic retry mechanism handles these transparently.
By default, failed jobs retry up to ten times with exponential backoff: immediate retry, then 1 minute, 2 minutes, 4 minutes, and so on. If all retries exhaust, the job transitions to the Failed state and appears in the dashboard. Administrators can manually re-enqueue failed jobs or investigate root causes using stack traces recorded in the database.
Custom retry logic uses filters:
public class CustomRetryAttribute : JobFilterAttribute, IElectStateFilter
{
public void OnStateElection(ElectStateContext context)
{
var failedState = context.CandidateState as FailedState;
if (failedState != null)
{
context.CandidateState = new ScheduledState(TimeSpan.FromMinutes(5));
}
}
}
[CustomRetry]
public void UnreliableTask()
{
// Custom retry: wait 5 minutes, then retry indefinitely
}
This filter intercepts state transitions and reschedules failed jobs with custom delays. Use cases include rate-limited APIs (retry after a cooldown), scheduled maintenance windows (skip retries during known outages), or critical workflows requiring infinite retries until manual intervention.
Hangfire also supports idempotency checks via filters. If a job should only execute once regardless of retries—for example, charging a customer’s credit card—wrap the logic in idempotency tokens or database locks to prevent duplicate execution.
Scalability: From Single Instance to Distributed Workers
Hangfire scales vertically and horizontally. Vertical scaling increases worker threads on a single server. Horizontal scaling adds more servers, each running its own Hangfire server instance. Workers across all servers poll the same database, coordinating via atomic database operations to prevent duplicate job processing.
When you deploy three application instances, each with five worker threads, you effectively have fifteen workers competing for jobs. Hangfire’s SQL-based storage uses UPDLOCK and READPAST to ensure only one worker claims each job. This coordination happens at the database level—no external message broker or distributed lock manager required.
For high-throughput scenarios—tens of thousands of jobs per minute—SQL Server’s polling overhead becomes noticeable. Each worker queries the database every few seconds, creating connection churn and CPU load. Redis-based storage reduces this overhead by leveraging Redis’s pub/sub for instant job notifications instead of polling. Workers sleep until Redis signals a new job, eliminating unnecessary queries.
Switching to Redis:
dotnet add package Hangfire.Pro.Redis
builder.Services.AddHangfire(configuration => configuration
.UseRedisStorage("localhost:6379"));
Redis also supports job prioritization, faster dashboard queries, and lower database load. The trade-off: Redis is eventually consistent, so job visibility (dashboard updates) may lag slightly compared to SQL Server’s strong consistency.
Another scalability concern: long-running jobs. If a job takes an hour to complete, it ties up a worker thread for that duration. Consider splitting long-running jobs into smaller units or processing them on dedicated servers with higher worker counts. Hangfire’s queue-based architecture supports this: route long-running jobs to a specific queue processed by dedicated servers.
using Hangfire.States;
BackgroundJob.Enqueue<IReportGenerator>(x => x.GenerateLargeReport(), new EnqueuedState("reports"));
Configure a dedicated server to process only the reports queue:
builder.Services.AddHangfireServer(options =>
{
options.Queues = new[] { "reports" };
options.WorkerCount = 2; // Limit to two concurrent reports
});
This isolates resource-intensive jobs from standard background work, preventing them from starving other tasks.
Dashboard and Observability
Hangfire’s dashboard is one of its most compelling features. It provides real-time visibility into job states without requiring custom telemetry or logging integration.
The dashboard displays:
- Enqueued jobs: Waiting for worker threads.
- Processing jobs: Currently executing, with elapsed time and server information.
- Scheduled jobs: Delayed or recurring jobs awaiting their trigger time.
- Succeeded jobs: Completed successfully, with execution duration.
- Failed jobs: Errors, stack traces, and retry counts.
- Recurring jobs: Cron schedules, last execution time, next execution time.
Administrators can manually trigger recurring jobs, delete failed jobs, or re-enqueue them for retry—all from the dashboard without writing code or deploying updates. This operational flexibility reduces time spent diagnosing background job issues.
Security considerations: the dashboard exposes sensitive information—job parameters, stack traces, server names. Protect it using authentication middleware:
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[] { new MyAuthorizationFilter() }
});
Implement IDashboardAuthorizationFilter to restrict access based on roles, authentication status, or IP address.
For production systems, consider integrating Hangfire with external monitoring tools. Export job metrics—succeeded jobs per minute, average execution time, retry rates—to Prometheus, Application Insights, or Datadog. Hangfire’s extensibility via filters and listeners makes this straightforward.
When Hangfire Fits
Hangfire excels in scenarios where:
Persistence is non-negotiable: Jobs must survive application restarts, deployments, or server reboots. Examples: user-initiated reports, data imports, long-running workflows.
Observability matters: Teams need real-time visibility into job states without building custom dashboards or integrating logging frameworks.
Web applications dominate your architecture: Hangfire integrates seamlessly with ASP.NET Core, leveraging existing database infrastructure without requiring separate message brokers or coordination services.
Moderate throughput suffices: Thousands of jobs per minute work well. If you need hundreds of thousands, consider Redis-based storage or evaluate Quartz.NET for advanced clustering.
Automatic retries reduce operational burden: Teams that value hands-off error handling benefit from Hangfire’s built-in retry policies, eliminating custom retry logic.
Hangfire is less suitable when:
Stateless deployments are required: Kubernetes environments favoring ephemeral pods may prefer in-memory schedulers like NCronJob, though Hangfire’s database dependency isn’t prohibitive if managed databases are available.
Sub-second latency is critical: Hangfire’s polling mechanism introduces latency (typically 1-5 seconds). Real-time event-driven systems might prefer message brokers like RabbitMQ or Azure Service Bus.
Complex scheduling is paramount: While Hangfire supports cron expressions, it lacks Quartz.NET’s advanced features like job calendars, misfire handling, or priority-based execution.
Operational Benefits and Trade-offs
Hangfire’s primary operational benefit is reliability. Jobs stored in a database won’t vanish due to application crashes or restarts. Administrators gain confidence that critical workflows—nightly data synchronization, scheduled email campaigns, periodic cache refreshes—execute reliably even during infrastructure turbulence.
The dashboard reduces debugging time. Instead of parsing logs to determine whether a job ran, succeeded, or failed, teams view job states in real-time. Failed jobs display stack traces inline, enabling root cause analysis without log aggregation tools.
Automatic retries reduce operational overhead. Transient failures—network blips, temporary service unavailability—self-heal without manual intervention. Teams spend less time monitoring background jobs and more time building features.
The trade-offs: database dependency and polling overhead. Teams must provision and maintain a database, configure connection strings, and monitor database health. In cloud environments, this might mean managed SQL instances (Azure SQL, Amazon RDS) with associated costs. Polling introduces latency and database load—acceptable for most workloads but noticeable in high-throughput or latency-sensitive scenarios.
Practical Takeaways
Hangfire occupies the middle ground between simplicity and enterprise-grade features. It provides persistence without requiring clustering, visibility without custom telemetry, and retries without manual logic. For ASP.NET Core applications needing reliable background processing, Hangfire delivers substantial value with moderate operational complexity.
Consider Hangfire if:
- Your application uses SQL Server, PostgreSQL, or Redis.
- Jobs must survive restarts and benefit from automatic retries.
- You value built-in dashboards over custom monitoring solutions.
- Throughput requirements are moderate (thousands per minute, not hundreds of thousands).
Avoid Hangfire if:
- You need stateless, zero-dependency deployments (see NCronJob or Coravel).
- Complex scheduling with calendars and advanced triggers is essential (see Quartz.NET).
- Ultra-low latency or extremely high throughput is required (consider message brokers or TickerQ).
The next article explores Quartz.NET, a framework that extends Hangfire’s persistence model with enterprise-grade features: clustering, advanced scheduling semantics, and multi-datacenter coordination. Where Hangfire simplifies reliability for web applications, Quartz.NET targets systems with complex scheduling demands and high-scale distributed deployments.
