
Hangfire Monitoring Best Practices for .NET Applications
Complete guide to monitoring Hangfire background jobs in production: built-in dashboard, custom monitoring, OpenTelemetry integration, and framework-native solutions. Real C# examples and best practices.
Hangfire makes background job processing in .NET applications straightforward—fire-and-forget jobs, delayed tasks, recurring jobs, all with a simple API. But in production, you need more than fire-and-forget. You need fire-and-verify.
Background jobs fail. Databases become unavailable. APIs timeout. External services go down. And when Hangfire jobs fail, your application keeps running—payments don't process, reports aren't generated, data synchronization stops—while you remain blissfully unaware until customers complain.
This guide covers production-grade monitoring strategies for Hangfire, from the built-in dashboard to custom telemetry and framework-native monitoring solutions.
How Hangfire Job Processing Works
Before diving into monitoring, let's review Hangfire's architecture.
Job Types
Hangfire supports four job types:
// 1. Fire-and-forget (executes once, as soon as possible)
BackgroundJob.Enqueue(() => SendEmail(orderId));
// 2. Delayed (executes once, after a delay)
BackgroundJob.Schedule(() => SendReminder(userId), TimeSpan.FromDays(7));
// 3. Recurring (executes repeatedly on a schedule)
RecurringJob.AddOrUpdate(
"database-backup",
() => BackupDatabase(),
Cron.Daily
);
// 4. Continuations (executes after another job completes)
var parentId = BackgroundJob.Enqueue(() => ProcessOrder(orderId));
BackgroundJob.ContinueJobWith(parentId, () => SendConfirmation(orderId));
Processing Pipeline
When you enqueue a job:
- Hangfire serializes the job method and parameters
- Stores job data in SQL Server, PostgreSQL, Redis, or another storage
- Background workers poll for pending jobs
- Workers execute jobs and update status
- Completed/failed jobs are tracked in storage
Why Monitoring is Critical
Hangfire's built-in features don't include:
- Proactive alerting when jobs fail
- External notifications (Slack, PagerDuty, email)
- Trend analysis for performance degradation
- Dead man's switch detection (recurring jobs that stop running)
- Centralized visibility across multiple applications
Without proper monitoring, failed background jobs become silent failures that impact your business.
Method 1: Built-In Dashboard Monitoring
Hangfire includes a web-based dashboard for job monitoring.
Enabling the Dashboard
// Startup.cs or Program.cs
public void Configure(IApplicationBuilder app)
{
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[] { new HangfireAuthorizationFilter() }
});
}
Dashboard Features
The dashboard provides:
- Jobs by state: Enqueued, processing, succeeded, failed, scheduled
- Real-time updates: Auto-refreshing job counts
- Job details: Method name, parameters, creation time, execution time
- Retry history: Failed attempts and retry schedules
- Server information: Active workers, queues, storage statistics
Monitoring Failed Jobs
Access failed jobs programmatically:
using Hangfire;
using Hangfire.Storage;
public class FailedJobMonitor
{
private readonly IMonitoringApi _monitoring;
public FailedJobMonitor(JobStorage storage)
{
_monitoring = storage.GetMonitoringApi();
}
public List<FailedJobDto> GetRecentFailures(int count = 50)
{
var failedJobs = _monitoring.FailedJobs(0, count);
return failedJobs.Select(kvp => new FailedJobDto
{
Id = kvp.Key,
Job = kvp.Value.Job,
Reason = kvp.Value.ExceptionMessage,
FailedAt = kvp.Value.FailedAt
}).ToList();
}
public int GetFailedJobCount()
{
var stats = _monitoring.GetStatistics();
return Convert.ToInt32(stats.Failed);
}
}
Creating Alerts from Dashboard Data
Poll for failures and send alerts:
public class HangfireHealthCheck : BackgroundService
{
private readonly IMonitoringApi _monitoring;
private readonly INotificationService _notifications;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var stats = _monitoring.GetStatistics();
var failedCount = Convert.ToInt32(stats.Failed);
if (failedCount > 10)
{
await _notifications.SendAlert(
$"Hangfire: {failedCount} failed jobs detected"
);
}
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
Dashboard Limitations
Pros:
- ✅ Free and built-in
- ✅ Real-time visibility
- ✅ Detailed job information
- ✅ Manual job retry/delete
Cons:
- ❌ No proactive alerting
- ❌ Requires manual checking
- ❌ No integration with external tools
- ❌ Doesn't detect recurring jobs that stop running
- ❌ No historical trend analysis
- ❌ Access requires VPN/authentication for production
Best for: Development and manual debugging, not production monitoring.
Method 2: Custom Job Filters for Monitoring
Hangfire supports filters that execute before/after job processing.
Creating a Monitoring Filter
using Hangfire.Common;
using Hangfire.Server;
using System.Diagnostics;
public class MonitoringJobFilter : IServerFilter
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<MonitoringJobFilter> _logger;
public MonitoringJobFilter(
IHttpClientFactory httpClientFactory,
ILogger<MonitoringJobFilter> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public void OnPerforming(PerformingContext filterContext)
{
var jobName = GetJobName(filterContext.BackgroundJob.Job);
var stopwatch = Stopwatch.StartNew();
filterContext.Items["Stopwatch"] = stopwatch;
filterContext.Items["JobName"] = jobName;
// Report job start
_ = ReportJobStart(jobName);
}
public void OnPerformed(PerformedContext filterContext)
{
var jobName = filterContext.Items["JobName"] as string;
var stopwatch = filterContext.Items["Stopwatch"] as Stopwatch;
stopwatch?.Stop();
if (filterContext.Exception == null)
{
// Job succeeded
_ = ReportJobSuccess(jobName, stopwatch?.ElapsedMilliseconds ?? 0);
}
else
{
// Job failed
_ = ReportJobFailure(
jobName,
filterContext.Exception.Message,
stopwatch?.ElapsedMilliseconds ?? 0
);
}
}
private async Task ReportJobStart(string jobName)
{
try
{
var client = _httpClientFactory.CreateClient();
await client.PostAsync(
$"https://monitor.example.com/ping/{jobName}/start",
null
);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to report job start for {JobName}", jobName);
}
}
private async Task ReportJobSuccess(string jobName, long durationMs)
{
try
{
var client = _httpClientFactory.CreateClient();
await client.PostAsync(
$"https://monitor.example.com/ping/{jobName}/complete?duration={durationMs}",
null
);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to report job success for {JobName}", jobName);
}
}
private async Task ReportJobFailure(string jobName, string error, long durationMs)
{
try
{
var client = _httpClientFactory.CreateClient();
var content = new StringContent(
$"{{\"message\":\"{error}\",\"duration\":{durationMs}}}",
System.Text.Encoding.UTF8,
"application/json"
);
await client.PostAsync(
$"https://monitor.example.com/ping/{jobName}/fail",
content
);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to report job failure for {JobName}", jobName);
}
}
private string GetJobName(Job job)
{
// Extract readable job name from method
return $"{job.Type.Name}.{job.Method.Name}";
}
}
Registering the Filter
// In Startup.cs or Program.cs
services.AddHangfire(config =>
{
config.UseSqlServerStorage(connectionString);
config.UseFilter(new MonitoringJobFilter(httpClientFactory, logger));
});
Global vs. Job-Specific Filters
Apply filters globally or per job:
// Global filter (applies to all jobs)
GlobalJobFilters.Filters.Add(new MonitoringJobFilter());
// Job-specific filter
[JobDisplayName("Database Backup")]
[MonitoredJob("database-backup")]
public async Task BackupDatabase()
{
// Job logic
}
Monitoring Recurring Jobs
Recurring jobs need special attention:
public class RecurringJobMonitor
{
private readonly JobStorage _storage;
public List<RecurringJobDto> GetRecurringJobs()
{
using var connection = _storage.GetConnection();
return connection.GetRecurringJobs();
}
public RecurringJobDto GetJobStatus(string jobId)
{
var jobs = GetRecurringJobs();
return jobs.FirstOrDefault(j => j.Id == jobId);
}
public async Task CheckForMissedJobs()
{
var jobs = GetRecurringJobs();
foreach (var job in jobs)
{
if (job.LastExecution.HasValue)
{
var elapsed = DateTime.UtcNow - job.LastExecution.Value;
var expectedInterval = ParseCronExpression(job.Cron);
if (elapsed > expectedInterval * 2)
{
// Job hasn't run in 2x expected interval
await AlertMissedJob(job.Id, elapsed);
}
}
}
}
}
Pros and Cons
Pros:
- ✅ Custom logic and integrations
- ✅ Lifecycle tracking (start/success/fail)
- ✅ Duration tracking
- ✅ Works with external monitoring services
Cons:
- ❌ Requires code for each integration
- ❌ Manual setup for recurring job detection
- ❌ Need to build alerting logic
- ❌ Maintenance overhead
Best for: Teams that want custom integrations but can build monitoring infrastructure.
Method 3: OpenTelemetry Integration
For teams using OpenTelemetry, integrate Hangfire with distributed tracing.
Installing OpenTelemetry
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Exporter.Console
Hangfire OpenTelemetry Filter
using OpenTelemetry.Trace;
using System.Diagnostics;
public class OpenTelemetryJobFilter : IServerFilter
{
private static readonly ActivitySource ActivitySource =
new ActivitySource("Hangfire.Jobs");
public void OnPerforming(PerformingContext filterContext)
{
var jobName = GetJobName(filterContext.BackgroundJob.Job);
var activity = ActivitySource.StartActivity(
$"Job: {jobName}",
ActivityKind.Internal
);
activity?.SetTag("job.id", filterContext.BackgroundJob.Id);
activity?.SetTag("job.type", jobName);
activity?.SetTag("job.queue", filterContext.GetJobParameter<string>("Queue") ?? "default");
filterContext.Items["Activity"] = activity;
}
public void OnPerformed(PerformedContext filterContext)
{
var activity = filterContext.Items["Activity"] as Activity;
if (filterContext.Exception != null)
{
activity?.SetStatus(ActivityStatusCode.Error, filterContext.Exception.Message);
activity?.RecordException(filterContext.Exception);
}
else
{
activity?.SetStatus(ActivityStatusCode.Ok);
}
activity?.Stop();
}
private string GetJobName(Job job)
{
return $"{job.Type.Name}.{job.Method.Name}";
}
}
Configuring OpenTelemetry
services.AddOpenTelemetry()
.WithTracing(builder =>
{
builder
.AddSource("Hangfire.Jobs")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddConsoleExporter();
});
// Add filter to Hangfire
GlobalJobFilters.Filters.Add(new OpenTelemetryJobFilter());
Sending to APM Tools
Export to Datadog, New Relic, or Application Insights:
services.AddOpenTelemetry()
.WithTracing(builder =>
{
builder
.AddSource("Hangfire.Jobs")
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri("https://your-apm-endpoint");
options.Headers = $"api-key={apiKey}";
});
});
Pros and Cons
Pros:
- ✅ Integrates with existing observability stack
- ✅ Distributed tracing across services
- ✅ Rich context and metadata
- ✅ Standard instrumentation
Cons:
- ❌ Requires OpenTelemetry infrastructure
- ❌ Doesn't detect missed recurring jobs
- ❌ APM tools can be expensive
- ❌ Complex setup
Best for: Teams already using OpenTelemetry/APM who want unified observability.
Method 4: Framework-Native Auto-Discovery
Use CronRadar's Hangfire integration for automatic monitoring.
Installation
dotnet add package CronRadar.Hangfire
Configuration
Add to appsettings.json:
{
"CronRadar": {
"ApiKey": "your-api-key",
"Enabled": true
}
}
Enable Monitoring
using CronRadar.Hangfire;
// In Startup.cs or Program.cs
services.AddHangfire(config =>
{
config.UseSqlServerStorage(connectionString);
config.UseCronRadar(); // Add monitoring
});
Auto-Discovery
All recurring jobs are automatically discovered and monitored:
// These are automatically monitored
RecurringJob.AddOrUpdate(
"database-backup",
() => BackupDatabase(),
Cron.Daily
);
RecurringJob.AddOrUpdate(
"process-payments",
() => ProcessPendingPayments(),
"*/5 * * * *" // Every 5 minutes
);
RecurringJob.AddOrUpdate(
"generate-reports",
() => GenerateWeeklyReports(),
Cron.Weekly(DayOfWeek.Monday, 8)
);
Monitoring Fire-and-Forget Jobs
For one-time jobs, wrap with monitoring:
BackgroundJob.Enqueue(() => SendEmail(orderId).WithMonitoring("send-email"));
Or use an attribute:
[MonitoredJob("order-processing")]
public async Task ProcessOrder(int orderId)
{
// Job logic
}
Custom Configuration
Override defaults per job:
RecurringJob.AddOrUpdate(
"long-running-report",
() => GenerateBigReport(),
Cron.Daily,
new RecurringJobOptions
{
Metadata = new Dictionary<string, string>
{
["CronRadar:GracePeriod"] = "1800", // 30 minutes
["CronRadar:Timeout"] = "3600" // 1 hour
}
}
);
Pros and Cons
Pros:
- ✅ Zero-config auto-discovery
- ✅ Automatic schedule detection
- ✅ Built-in alerting (Slack, Teams, PagerDuty, etc.)
- ✅ Dead man's switch for recurring jobs
- ✅ Historical data and trends
- ✅ Team collaboration
- ✅ No infrastructure to maintain
Cons:
- ❌ External service dependency
- ❌ Paid after trial period
- ❌ Data sent outside your infrastructure
Best for: Teams that want comprehensive monitoring without building infrastructure.
Best Practices for Hangfire Monitoring
1. Use Meaningful Job IDs
// Good
RecurringJob.AddOrUpdate(
"backup-production-db",
() => BackupDatabase("production"),
Cron.Daily
);
// Bad
RecurringJob.AddOrUpdate(
"job1",
() => BackupDatabase("production"),
Cron.Daily
);
2. Set Appropriate Timeouts
[AutomaticRetry(Attempts = 3)]
[DisableConcurrentExecution(timeoutInSeconds: 3600)]
public async Task LongRunningJob()
{
using var cts = new CancellationTokenSource(TimeSpan.FromHours(1));
await DoWorkAsync(cts.Token);
}
3. Implement Proper Error Handling
public async Task ProcessPayments()
{
try
{
var payments = await GetPendingPayments();
foreach (var payment in payments)
{
try
{
await ProcessPayment(payment);
}
catch (Exception ex)
{
// Log individual payment failure but continue
_logger.LogError(ex, "Failed to process payment {PaymentId}", payment.Id);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve pending payments");
throw; // Let Hangfire handle retry
}
}
4. Monitor Job Queues
public class QueueMonitor
{
public Dictionary<string, long> GetQueueLengths()
{
var monitoring = JobStorage.Current.GetMonitoringApi();
var queues = monitoring.Queues();
return queues.ToDictionary(
q => q.Name,
q => q.Length
);
}
public async Task AlertOnLongQueues(int threshold = 1000)
{
var queues = GetQueueLengths();
foreach (var queue in queues.Where(q => q.Value > threshold))
{
await SendAlert($"Queue '{queue.Key}' has {queue.Value} pending jobs");
}
}
}
5. Use Job Continuations Wisely
var step1 = BackgroundJob.Enqueue(() => DownloadData(url));
var step2 = BackgroundJob.ContinueJobWith(step1, () => ProcessData());
var step3 = BackgroundJob.ContinueJobWith(step2, () => NotifyComplete());
// Monitor the entire chain, not just individual steps
6. Test in Staging
#if DEBUG
// In development, run jobs immediately for testing
RecurringJob.AddOrUpdate(
"test-job",
() => TestJob(),
Cron.Minutely
);
#else
// In production, use actual schedule
RecurringJob.AddOrUpdate(
"test-job",
() => TestJob(),
Cron.Daily
);
#endif
Troubleshooting Common Issues
Jobs Not Processing
Check worker count:
app.UseHangfireServer(new BackgroundJobServerOptions
{
WorkerCount = Environment.ProcessorCount * 5
});
Verify SQL Server storage:
SELECT COUNT(*) FROM HangFire.Job WHERE StateName = 'Enqueued';
SELECT COUNT(*) FROM HangFire.Server; -- Should show active servers
Check for exceptions:
var failedJobs = monitoring.FailedJobs(0, 100);
foreach (var job in failedJobs)
{
Console.WriteLine($"Job {job.Key}: {job.Value.ExceptionMessage}");
}
Recurring Jobs Not Running
Verify job exists:
using var connection = JobStorage.Current.GetConnection();
var recurringJobs = connection.GetRecurringJobs();
var job = recurringJobs.FirstOrDefault(j => j.Id == "my-job");
if (job == null)
{
Console.WriteLine("Job not found!");
}
else
{
Console.WriteLine($"Next execution: {job.NextExecution}");
Console.WriteLine($"Last execution: {job.LastExecution}");
}
Check for timezone issues:
RecurringJob.AddOrUpdate(
"daily-job",
() => DoWork(),
Cron.Daily,
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time")
}
);
High Memory Usage
Limit concurrent jobs:
app.UseHangfireServer(new BackgroundJobServerOptions
{
WorkerCount = 5, // Reduce from default
Queues = new[] { "critical", "default", "low" }
});
Use job batches for bulk operations:
// Instead of enqueuing 10,000 individual jobs
foreach (var item in items)
{
BackgroundJob.Enqueue(() => Process(item));
}
// Use batches
var batchSize = 100;
var batches = items.Chunk(batchSize);
foreach (var batch in batches)
{
BackgroundJob.Enqueue(() => ProcessBatch(batch));
}
Production Checklist
Before going to production:
- [ ] Dashboard authentication configured
- [ ] Monitoring enabled for critical recurring jobs
- [ ] Alerts configured (Slack, email, PagerDuty)
- [ ] Job timeouts set appropriately
- [ ] Error handling and retry logic implemented
- [ ] Queue monitoring in place
- [ ] Worker count optimized for load
- [ ] SQL Server indexes added (for performance)
- [ ] Job serialization tested (no large payloads)
- [ ] Recurring job schedules validated
Conclusion
Hangfire's built-in dashboard is excellent for development but insufficient for production monitoring. You need proactive alerting, external notifications, and dead man's switch detection for recurring jobs.
Your options:
- Built-in dashboard - Manual monitoring, good for debugging
- Custom filters - Full control, requires infrastructure
- OpenTelemetry - Unified observability, complex setup
- Auto-discovery - Zero maintenance, managed service
Choose based on your team's size, technical capabilities, and existing infrastructure.
Start by monitoring your most critical jobs—payment processing, data synchronization, database backups. Then expand coverage. The cost of monitoring is trivial compared to the cost of undetected failures in production.
Monitor all Hangfire jobs automatically. CronRadar's Hangfire integration auto-discovers recurring jobs, tracks execution, and alerts your team. Start monitoring in 5 minutes →


