Free ServiceCronRadar is completely free for the first half of 2026!

Monitoring Hangfire Background Jobs: A Practical Guide

Cronradar··10 min

Hangfire makes background job processing in .NET remarkably simple. You call BackgroundJob.Enqueue(), and the job runs in the background. But here's what the documentation doesn't emphasize: when those jobs fail in production, Hangfire won't tell you.

One developer shared this experience on the Hangfire forums: their production server stopped processing jobs entirely, and they didn't discover it for 1.5 days—until a customer reported the issue. The Hangfire dashboard showed everything looked fine. The host application kept running. No exceptions bubbled up. Complete silence.

This guide covers how to properly monitor Hangfire jobs in production, from understanding the built-in dashboard's limitations to implementing external monitoring that actually alerts you when things go wrong.

Understanding Hangfire's Architecture

Before diving into monitoring strategies, it helps to understand how Hangfire processes jobs. The system has three core components:

Client: When you call BackgroundJob.Enqueue(), the client serializes your method call (including the method name, type, and arguments) and persists it to storage. Control returns immediately to your application—the job hasn't executed yet.

Storage: This persistence layer (SQL Server, Redis, PostgreSQL, or MongoDB) holds all job data, state transitions, and metadata. Jobs survive application restarts, server reboots, and IIS app pool recycles because everything is persisted here.

Server: The BackgroundJobServer runs dedicated background threads that poll storage for new jobs, fetch them using distributed locks, and execute them. Multiple servers can run simultaneously, coordinating through the storage layer.

This architecture provides durability—jobs won't be lost if your application crashes. But it also means failures can happen silently in those background threads without affecting your main application.

The Four Job Types

Hangfire supports four job types, each with different monitoring considerations:

// Fire-and-forget: executes immediately (once)
BackgroundJob.Enqueue(() => SendWelcomeEmail(userId));

// Delayed: executes after specified time
BackgroundJob.Schedule(() => SendFollowUpEmail(userId), TimeSpan.FromDays(3));

// Recurring: executes on a CRON schedule
RecurringJob.AddOrUpdate("daily-report", () => GenerateDailyReport(), Cron.Daily);

// Continuation: executes after parent job completes
BackgroundJob.ContinueJobWith(parentJobId, () => NotifyCompletion());

Recurring jobs require particular attention—they need an "always running" server configuration in IIS (startMode="AlwaysRunning"), and missed executions can go unnoticed if you're not actively monitoring.

Why the Hangfire Dashboard Isn't Enough

Hangfire ships with a built-in dashboard that provides real-time visibility into your job processing. You can see job counts, browse succeeded and failed jobs, view stack traces, manually retry failed jobs, and monitor active servers.

For development and debugging, it's excellent. For production monitoring, it has critical gaps.

No Alerting

The dashboard has no built-in notification system. When a job fails, when retries exhaust, when a recurring job misses its schedule, when a server goes offline—the dashboard displays this information, but it won't alert you. You have to be looking at the dashboard at the right moment to notice problems.

There's no email integration. No Slack notifications. No webhook support. No PagerDuty connection. If you want to know about failures without manually checking the dashboard, you need to build something yourself.

Requires Active Monitoring

The dashboard is a web interface that requires someone to look at it. In practice, this means problems get discovered in one of three ways:

  1. Someone happens to check the dashboard and notices failed jobs
  2. A customer reports that something didn't happen (email not sent, report not generated)
  3. A downstream system fails because expected data wasn't processed

None of these are acceptable for production systems where background jobs handle critical workflows like payment processing, report generation, or data synchronization.

The dashboard shows current state—how many jobs are queued right now, how many failed today. It doesn't provide historical graphs, SLA tracking, or trend analysis. You can't easily answer questions like "are failures increasing?" or "what's our average job duration this week versus last week?"

Silent Server Failures

Perhaps the most dangerous limitation: Hangfire servers can die without the dashboard knowing. The background threads crash, the host application keeps running, and the dashboard shows stale server entries until their heartbeat timeout expires. This is documented in GitHub Issue #851, where developers describe discovering dead servers hours or days after they stopped processing.

Common Hangfire Failure Modes

Understanding how Hangfire fails helps you design effective monitoring. These are the patterns developers encounter most frequently in production.

Jobs Stuck in Processing State

This is one of the most frustrating failure modes. Jobs enter the "Processing" state and never leave. All workers appear busy, but nothing is actually executing.

From GitHub Issue #2311: "We've had this issue across all of our Hangfire installs, across probably 10+ apps, where all queues will just completely hang. This has been a massive headache for us... the only solution is to restart the app."

Common causes include:

  • Server crash during job execution (the job was claimed but never completed)
  • Redis client timeout issues losing the connection mid-execution
  • Long-running jobs exceeding the invisibility timeout
  • Deadlocks in job code that never throw an exception

The dashboard shows jobs as "Processing," but they're actually abandoned. Without external monitoring checking for jobs stuck in this state beyond expected duration, you won't know until the queue backs up significantly.

Silent Retry Exhaustion

Hangfire automatically retries failed jobs with exponential backoff—10 attempts by default. This is generally helpful, but it creates a monitoring blind spot.

[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Fail)]
public void ProcessPayment(int orderId)
{
    // If this fails 3 times, it moves to Failed state permanently
    // No notification is sent by default
}

When retries exhaust, the job moves to the Failed state and sits there. Critical business processes can fail completely with no alert. You only discover the problem when investigating why expected outcomes didn't occur.

Recurring Jobs Not Triggering

Recurring jobs have their own failure mode: they simply don't run. The schedule exists, but executions don't happen.

Common causes include:

  • IIS app pool recycling without startMode="AlwaysRunning" configured
  • Server not running during the scheduled window
  • CRON expression timezone mismatches
  • Multiple servers with clock drift causing schedule conflicts

From a developer on the Hangfire forums: "Only two of my five recurring jobs trigger when they should." The dashboard shows the recurring job is registered with its next execution time, but that execution never happens. Without external monitoring expecting a ping at specific intervals, missed executions go unnoticed.

Server Death Without Exception

The most insidious failure: your Hangfire server stops processing jobs, but no exception is thrown, no error is logged, and your host application keeps running normally.

This happens when:

  • Background threads die due to unhandled exceptions in infrastructure code
  • Thread pool starvation prevents workers from executing
  • Memory pressure causes the garbage collector to pause workers indefinitely
  • Assembly loading failures (especially Newtonsoft.Json version conflicts) break deserialization silently

Your API keeps responding to requests. Your health checks pass. Everything looks fine—except jobs are piling up in the queue.

Using the Hangfire Monitoring API

Hangfire exposes an IMonitoringApi that provides programmatic access to job statistics. This is your foundation for building monitoring that goes beyond the dashboard.

// Get the monitoring API from current storage
var monitoringApi = JobStorage.Current.GetMonitoringApi();

// Get overall statistics
var stats = monitoringApi.GetStatistics();
Console.WriteLine($"Queued: {stats.Enqueued}");
Console.WriteLine($"Processing: {stats.Processing}");
Console.WriteLine($"Succeeded: {stats.Succeeded}");
Console.WriteLine($"Failed: {stats.Failed}");

// Get failed jobs with details
var failedJobs = monitoringApi.FailedJobs(0, 50);
foreach (var job in failedJobs)
{
    Console.WriteLine($"Job {job.Key} failed: {job.Value.ExceptionMessage}");
}

// Check for jobs stuck in processing
var processingJobs = monitoringApi.ProcessingJobs(0, 100);
foreach (var job in processingJobs)
{
    var duration = DateTime.UtcNow - job.Value.StartedAt;
    if (duration > TimeSpan.FromMinutes(30))
    {
        Console.WriteLine($"Job {job.Key} has been processing for {duration}");
    }
}

You can use this API to build custom monitoring endpoints, scheduled health checks, or integration with your existing monitoring infrastructure.

Checking Recurring Job Status

For recurring jobs, you can verify whether they're executing on schedule:

using Hangfire.Storage;

var connection = JobStorage.Current.GetConnection();
var recurringJobs = connection.GetRecurringJobs();

foreach (var job in recurringJobs)
{
    var lastExecution = job.LastExecution;
    var nextExecution = job.NextExecution;
    
    // Check if the job should have run but didn't
    if (lastExecution.HasValue && nextExecution.HasValue)
    {
        if (nextExecution.Value < DateTime.UtcNow)
        {
            Console.WriteLine($"Recurring job '{job.Id}' missed its scheduled execution");
        }
    }
}

Setting Up ASP.NET Core Health Checks

The AspNetCore.HealthChecks.Hangfire package integrates Hangfire monitoring with ASP.NET Core's health check system. This gives you a standardized endpoint that container orchestrators like Kubernetes can use to determine application health.

First, install the package:

dotnet add package AspNetCore.HealthChecks.Hangfire

Then configure health checks in your startup:

// In Program.cs or Startup.cs
builder.Services.AddHealthChecks()
    .AddHangfire(options =>
    {
        options.MaximumJobsFailed = 10;       // Unhealthy if more than 10 failed jobs
        options.MinimumAvailableServers = 1;  // Unhealthy if no servers are processing
    });

// Map the health check endpoint
app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

Now /health returns a structured response indicating whether Hangfire is healthy based on your thresholds:

{
  "status": "Healthy",
  "entries": {
    "hangfire": {
      "status": "Healthy",
      "description": "Hangfire is healthy",
      "data": {
        "FailedJobs": 2,
        "Servers": 3
      }
    }
  }
}

This integrates with Kubernetes liveness and readiness probes, letting your orchestrator restart pods when Hangfire becomes unhealthy:

livenessProbe:
  httpGet:
    path: /health
    port: 80
  initialDelaySeconds: 30
  periodSeconds: 10

The limitation of health checks is they're pull-based—something needs to call the endpoint. They won't proactively alert you. They work well for container orchestration but don't replace push-based alerting for operational awareness.

Implementing Custom Job Filters for Monitoring

Hangfire's filter system lets you intercept job execution at key lifecycle points. This is where you can add external monitoring integration—sending pings when jobs start, succeed, or fail.

The filter interfaces you'll work with:

  • IServerFilter: Intercepts job execution with OnPerforming (before) and OnPerformed (after)
  • IElectStateFilter: Intercepts state determination, useful for capturing failures with exception details
  • IApplyStateFilter: Intercepts state transitions for comprehensive tracking

Here's a filter that sends HTTP pings to an external monitoring service:

public class MonitoringFilter : JobFilterAttribute, IServerFilter, IElectStateFilter
{
    private static readonly HttpClient HttpClient = new()
    {
        Timeout = TimeSpan.FromSeconds(5)
    };

    private readonly string _monitoringBaseUrl;

    public MonitoringFilter(string monitoringBaseUrl)
    {
        _monitoringBaseUrl = monitoringBaseUrl;
    }

    public void OnPerforming(PerformingContext context)
    {
        // Store start time for duration tracking
        context.Items["StartTime"] = DateTime.UtcNow;
        
        // Send "job started" ping
        var jobId = GetMonitorId(context);
        _ = SendPingAsync($"{_monitoringBaseUrl}/ping/{jobId}/start");
    }

    public void OnPerformed(PerformedContext context)
    {
        var jobId = GetMonitorId(context);
        var startTime = context.Items["StartTime"] as DateTime?;
        var duration = startTime.HasValue 
            ? (int)(DateTime.UtcNow - startTime.Value).TotalMilliseconds 
            : 0;

        if (context.Exception == null || context.ExceptionHandled)
        {
            _ = SendPingAsync($"{_monitoringBaseUrl}/ping/{jobId}/complete?duration={duration}");
        }
        else
        {
            var errorMessage = Uri.EscapeDataString(context.Exception.Message);
            _ = SendPingAsync($"{_monitoringBaseUrl}/ping/{jobId}/fail?error={errorMessage}");
        }
    }

    public void OnStateElection(ElectStateContext context)
    {
        // Capture failed state transitions (handles retry exhaustion)
        if (context.CandidateState is FailedState failedState)
        {
            var jobId = GetMonitorId(context);
            var errorMessage = Uri.EscapeDataString(failedState.Exception?.Message ?? "Unknown error");
            _ = SendPingAsync($"{_monitoringBaseUrl}/ping/{jobId}/fail?error={errorMessage}");
        }
    }

    private string GetMonitorId(dynamic context)
    {
        // Use recurring job ID if available, otherwise use background job ID
        var recurringJobId = context.GetJobParameter<string>("RecurringJobId");
        return recurringJobId ?? context.BackgroundJob.Id;
    }

    private async Task SendPingAsync(string url)
    {
        try
        {
            using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
            await HttpClient.GetAsync(url, cts.Token);
        }
        catch
        {
            // Never let monitoring failures affect job execution
        }
    }
}

Register the filter globally to apply to all jobs:

GlobalJobFilters.Filters.Add(new MonitoringFilter("https://cronradar.io"));

The key principle: monitoring should never impact job execution. Use fire-and-forget HTTP calls with short timeouts, and catch all exceptions. A monitoring service being down should not cause your jobs to fail.

External Monitoring with CronRadar

Building and maintaining custom monitoring filters works, but it requires ongoing effort—handling edge cases, managing HTTP client lifecycle, dealing with service scope issues in filters, and ensuring your monitoring infrastructure stays reliable.

CronRadar provides a purpose-built solution for Hangfire monitoring. The key differentiator is auto-discovery: instead of manually configuring each job, CronRadar automatically detects your Hangfire jobs and creates monitors for them.

Install the NuGet package:

dotnet add package CronRadar.Hangfire

Configure in your startup:

builder.Services.AddHangfire(config => config
    .UseSqlServerStorage(connectionString)
    .UseCronRadar(options =>
    {
        options.ApiKey = Configuration["CronRadar:ApiKey"];
        options.AutoDiscover = true;  // Automatically detect and monitor all jobs
    }));

That's the complete integration. CronRadar discovers your recurring jobs, tracks their expected schedules, and alerts you when:

  • A recurring job doesn't run when expected
  • A job fails (including retry exhaustion)
  • A job takes significantly longer than usual
  • A job gets stuck in the processing state

For jobs that need explicit monitoring configuration, you can use the attribute approach:

[CronRadar("payment-processor", GracePeriod = "5m")]
public async Task ProcessPendingPayments()
{
    // Critical payment processing that must run every hour
    // Alert if it doesn't complete within 5 minutes of expected time
}

The monitoring happens externally to your application, meaning you'll still get alerts even if your Hangfire server dies completely—exactly the failure mode that the built-in dashboard misses.

Production Monitoring Best Practices

Effective Hangfire monitoring requires more than just tooling—it requires thinking through your monitoring strategy.

Configure Always-Running for Recurring Jobs

If you're running Hangfire in IIS, recurring jobs won't trigger reliably unless you configure the app pool correctly:

<!-- applicationHost.config -->
<applicationPools>
    <add name="YourAppPool" startMode="AlwaysRunning" />
</applicationPools>

<sites>
    <site name="YourSite">
        <application path="/" preloadEnabled="true" />
    </site>
</sites>

Without this configuration, IIS suspends your application when there's no web traffic, and your recurring jobs simply don't run. External monitoring will detect this—your jobs will show as missed—but it's better to configure correctly upfront.

Set Appropriate Timeouts

Jobs that run too long create problems. They hold worker threads, delay other jobs, and may indicate performance issues. Set explicit timeouts:

[JobTimeout("00:30:00")]  // 30 minute timeout
public void LongRunningJob()
{
    // Hangfire will abort this job if it exceeds 30 minutes
}

Then configure your monitoring to alert when jobs approach their timeout threshold, not just when they exceed it.

Monitor Queue Depth

A growing queue indicates either increased load or processing problems. Set up alerts for queue depth thresholds:

var monitoringApi = JobStorage.Current.GetMonitoringApi();
var stats = monitoringApi.GetStatistics();

if (stats.Enqueued > 1000)
{
    // Alert: queue is backing up
}

Separate Critical Jobs

Consider using dedicated queues for critical jobs and monitoring each queue independently:

// Critical payment jobs get their own queue
[Queue("payments")]
public void ProcessPayment(int orderId) { }

// Non-critical jobs use default queue
[Queue("default")]
public void SendMarketingEmail(int userId) { }

This lets you set stricter monitoring thresholds for critical queues while allowing more flexibility for lower-priority work.

Test Your Monitoring

Monitoring that hasn't been tested might not work when you need it. Periodically verify:

  1. Force a job failure and confirm you receive an alert
  2. Stop a Hangfire server and confirm the alert fires
  3. Let a recurring job miss its schedule and verify detection
  4. Check that alert routing reaches the right people

Monitoring Checklist

Before deploying Hangfire to production, verify you have:

Infrastructure

  • [ ] App pool configured with startMode="AlwaysRunning" (IIS)
  • [ ] Multiple Hangfire servers for redundancy
  • [ ] Health check endpoint integrated with your orchestrator

Alerting

  • [ ] Alerts for failed jobs (not just logged, actually alerted)
  • [ ] Alerts for recurring jobs that miss their schedule
  • [ ] Alerts for jobs stuck in Processing beyond expected duration
  • [ ] Alerts for queue depth exceeding thresholds
  • [ ] Alerts for no active Hangfire servers

Operational Readiness

  • [ ] Alert routing to on-call team (not just a shared inbox)
  • [ ] Runbook for common failure scenarios
  • [ ] Tested alert delivery (confirmed alerts actually reach you)
  • [ ] Dashboard access for investigating issues

Hangfire is a reliable foundation for background job processing. But reliability in terms of job persistence is different from visibility into operational health. The built-in dashboard tells you what happened if you look at it. External monitoring tells you what's happening whether you're looking or not.

The difference becomes apparent at 2 AM when a critical job stops running. With proper monitoring, you get a Slack alert and can investigate immediately. Without it, you find out Monday morning when customers start complaining.

Set up external monitoring before you need it. Your future on-call self will thank you.

Start monitoring your cron jobs

Get started in minutes. No credit card required.