Hangfire Recurring Job Not Running? 9 Causes and How to Check
Contents
When a Hangfire recurring job doesn't run, one of three things happened: no background job was created from the schedule, a background job was created but no worker picked it up, or it ran and failed. The Recurring Jobs page in the Hangfire dashboard tells you which. If Last execution never moved, the problem is upstream of your code: the server, its process, the storage, or the recurring entry. If it moved, click it and read that background job's state and queue.
This guide covers Hangfire 1.8 in ASP.NET Core apps and .NET worker services. Source links point to Hangfire 1.8.25, the latest 1.8 release when checked on October 24, 2026.
Find the Step That Failed Before Changing Code#
A recurring job runs in two steps. The recurring job scheduler, a background process inside every full Hangfire server, sees that an entry is due and creates an ordinary background job. Then a worker listening on that job's queue executes it (recurring tasks, RecurringJobScheduler.cs).
| What you see | Likely cause | Check |
|---|---|---|
| Servers page: "There are no active servers" | No server uses this storage (1) | AddHangfireServer() in the right host |
| Next execution is in the past | No scheduler runs against this storage (1, 2, 4) | Servers page, process lifetime, connection string |
| It runs only after someone opens the site | IIS starts and stops the app with traffic (2) | App pool start mode, idle timeout, preload |
| Last execution moved, job sits in Enqueued | No server listens on its queue, or workers busy (3) | Job's queue vs. each server's Queues |
| Entry missing from Recurring Jobs | Never registered, other storage, or removed (4, 5) | Startup logs, storage settings, RemoveIfExists |
| Two entries for one job | The recurring job ID changed (5) | IDs in storage vs. IDs in code |
| Wrong hour or wrong days | Cron read in UTC, or day-field rules (6) | Time zone and expression |
| Next execution shows Error or Disabled | Scheduler error (7), or the cron never occurs (6) | The error row under the entry |
| Skipped while the app was down, ran once at startup | Default misfire handling (8) | MisfireHandling |
| Last execution label is Failed or Scheduled | The job threw and may be retrying (9) | Job details and retry reason |
| Last execution label stays Processing | Still running, or stuck (9) | Worker logs |
The Last execution label is colored by the state of the last background job the entry created and links to it (RecurringJobsPage.cshtml). Start there.
1. No Hangfire Server Is Processing That Storage#
AddHangfire() registers storage, the client, and the services the dashboard needs. It processes nothing. Processing comes from AddHangfireServer(), which adds the server "as IHostedService" (ASP.NET Core setup). An app with the dashboard but no AddHangfireServer() can register and display recurring jobs, and nothing runs them. The Servers page says so: "There are no active servers. Background tasks will not be processed."
Two less obvious versions of the same problem:
- The server lives in another service. If a web app registers jobs and a worker service runs the server, the worker must use the same storage and be able to load the job's types (cause 7).
- Every server is lightweight. A server with
IsLightweightServer = trueruns workers only, without the recurring and delayed job schedulers (BackgroundJobServerOptions.cs). One full server per storage is enough; schedulers coordinate through a distributed lock.
A host that schedules and runs recurring jobs, with the Hangfire.AspNetCore and Hangfire.SqlServer packages:
using Hangfire;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")));
// Without this call the app can register and display jobs but never runs them.
builder.Services.AddHangfireServer(options =>
{
// Workers fetch only from these queues. The default is just "default".
options.Queues = new[] { "reports", "default" };
});
var app = builder.Build();
app.UseHangfireDashboard();
// Resolving the manager from DI guarantees Hangfire's configuration has run.
var recurringJobs = app.Services.GetRequiredService<IRecurringJobManager>();
recurringJobs.AddOrUpdate<ReportJobs>(
"nightly-report",
"reports",
jobs => jobs.SendNightlyReport(),
"0 2 * * *");
app.Run();
public class ReportJobs
{
public void SendNightlyReport() => Console.WriteLine("Sending the nightly report");
}2. The Server's Process Was Asleep#
A server only schedules while its process is alive, and under IIS that isn't the default. "By default, Hangfire Server instance in a web application will not be started until the first user hits your site," and the idle timeout and pool recycling stop it again (Making ASP.NET application always running). While it's stopped, no recurring job is created. When a request wakes the app, the default misfire handling (cause 8) creates one catch-up run, so the job seems to run only when someone visits.
For ASP.NET Core on IIS, that page lists these settings:
- Application pool: .NET CLR v4.0, Integrated pipeline. With No managed code, "the application preload option won't work."
- Pool advanced settings: Start Mode Always Running, Idle Time-out 0.
- Application advanced settings: Preload Enabled True.
system.webServer/applicationInitialization:doAppInitAfterRestartTrue, with your Hangfire host name and dashboard path, such as/hangfire, as the initialization page.
On Azure App Service, turn on Always On. To confirm, recycle the pool, wait about five minutes, and check that the Servers page shows a server started five minutes ago, not one that started when you opened the dashboard.
If you can't control the web host's lifetime, run the server in a worker service or Windows service instead, and keep the dashboard and job registration in the web app. The worker then needs the job types (cause 7).
3. The Job Lands in a Queue No Server Listens To#
If Last execution moved but the job never started, open the background job. Enqueued means it's waiting for a worker. Workers fetch only from their server's Queues option, which defaults to default alone (BackgroundJobServerOptions.cs), so a job in any other queue waits for a server that lists it (configuring queues).
A recurring job gets its queue from one of two places:
- The
queueargument of the Hangfire 1.8AddOrUpdateoverloads, as above. The 1.8.0 announcement says the "queue name will be used only when creating a background job on its schedule." On storage without per-job queue support, this overload throwsNotSupportedExceptionat registration. - A
[Queue("reports")]attribute on the method or class. If both are set, the attribute's queue wins unless it isdefault(EnqueuedState.cs).
Queue names allow only lowercase letters, digits, underscores, and dashes. To compare where servers listen with where jobs wait:
using Hangfire;
public static class HangfireDiagnostics
{
// Call with app.Services.GetRequiredService<JobStorage>() in the affected app.
public static void PrintServersAndQueues(JobStorage storage)
{
var monitoring = storage.GetMonitoringApi();
foreach (var server in monitoring.Servers())
{
Console.WriteLine(
$"server {server.Name}: {server.WorkersCount} workers, " +
$"queues [{string.Join(", ", server.Queues)}], heartbeat {server.Heartbeat:u}");
}
foreach (var queue in monitoring.Queues())
{
Console.WriteLine($"queue {queue.Name}: {queue.Length} enqueued");
}
}
}A queue with waiting jobs that no server lists is the mismatch. If the queue is listed and jobs still wait, every worker may be busy: the default WorkerCount is five per processor, capped at 20.
4. The Registration Never Ran, or Wrote to Different Storage#
A recurring entry exists only after AddOrUpdate runs against that storage. If the entry is missing, find out whether registration ran and where it wrote:
- It threw at startup.
AddOrUpdatevalidates the expression and throwsArgumentException("CRON expression is invalid") at registration (RecurringJobManager.cs). The staticRecurringJobAPI throwsInvalidOperationExceptionwhenJobStorage.Currentisn't set yet; that message itself recommendsIRecurringJobManageroverRecurringJobin .NET Core apps (JobStorage.cs). - It didn't run in that environment. Look for registration behind an environment check, a feature flag, or in a service that wasn't deployed.
- It wrote somewhere else. Compare the connection string, the SQL Server
SchemaName(defaultHangFire, per Constants.cs), or the RedisPrefix(defaulthangfire:, per the Redis docs) across the registering app, the server, and the dashboard.
The dashboard shows only the storage it's connected to. If you can see the entry but the Servers page is empty, the dashboard and the server use different storage.
5. The Recurring Job ID Changed or Was Removed#
A recurring entry is keyed on its ID. AddOrUpdate with an existing ID updates it; a new ID creates a second entry. Rename nightly-report to reports-nightly and the old entry stays scheduled until something calls RemoveIfExists("nightly-report"). If the old entry's method was renamed too, it starts failing to schedule (cause 7).
Look for three things:
- Implicit IDs. Hangfire 1.8 made the overloads without an explicit ID obsolete. For non-generic methods they used
{TypeName}.{MethodName}(1.8.0 announcement), so renaming the class or method silently changed the ID. - Removal code.
RemoveIfExistsdeletes the entry and takes it off the schedule (RecurringJobManager.cs). Search code and deployment scripts for it, including cleanup loops that remove every job not in a list. The Recurring Jobs page also has a Delete button. - Case. The recurring-task docs warn that IDs may be case sensitive in some storage implementations.
6. The Cron Expression or Time Zone Means Something Else#
Hangfire reads the cron expression in UTC unless the entry has a time zone; RecurringJobOptions.TimeZone defaults to TimeZoneInfo.Utc (RecurringJobOptions.cs). 0 2 * * * without one means 02:00 UTC. The dashboard shows Next execution as a relative time, and the hover tooltip shows it in your browser's time zone, which can make a correct UTC schedule look wrong.
Two expression rules also catch people out. Hangfire evaluates cron with Cronos, which matches with AND when both day-of-month and day-of-week are restricted, where Vixie cron uses OR (Cronos README). So 0 9 1-7 * 1 is the first Monday in Hangfire, but every Monday plus the 1st through 7th in a crontab. And an expression with no future occurrence, such as February 30 (0 0 30 2 *), leaves the entry Disabled; the tooltip reads "Cron expression is invalid or doesn't have any occurrences over the next 100 years."
Hangfire cron expressions and time zones covers time zones and daylight saving time in depth.
7. The Scheduler Recorded an Error and Disabled the Job#
When an entry is due, the scheduler deserializes the stored job. If that throws, typically because the stored type or method can't be loaded in this server's process, the scheduler logs Recurring job 'nightly-report' can't be scheduled due to an error and will be retried in 00:00:15., records the error, and retries at the schedule polling interval, 15 seconds by default. After five retries it logs that the job "will be disabled," clears the next execution, and keeps the error (RecurringJobScheduler.cs). Nothing fires until the entry is registered again.
In the dashboard, Next execution shows a warning label while retrying and a red Error label once disabled, with the stack trace in a row under the entry. If the dashboard's own process can't load the type either, the Job column shows the load error. In code, the same facts are on RecurringJobDto as Error, RetryAttempt, LoadException, NextExecution, and LastJobState; Hangfire monitoring shows how to read the registry.
Common ways to get here:
- A refactor renamed or moved the job's class or method, and no code re-registers the entry that points at the old name. Usually an orphan from cause 5.
- Different code bases share one storage. Any server's scheduler can pick up any entry, so a server whose code lacks the type fails to schedule it. The 1.8.0 announcement is explicit: "it's still impossible to use the same storage for recurring jobs from different code bases." Give each code base its own storage, schema, or prefix.
- Old and new versions overlap during a deployment. An old instance that can't load a newly renamed type hits the same error while it's still running, and five 15-second retries pass quickly.
- The server can't resolve the entry's time zone ID. The scheduler looks up the stored ID each time the entry is due, inside the same error handling, so a zone one host knows and another doesn't fails the same way.
Re-registering the entry with AddOrUpdate clears the error and recalculates the next execution (RecurringJobManager.cs), so restarting the host that registers it brings the job back once its type loads. Remove orphans with RemoveIfExists.
8. The App Was Down When the Job Was Due#
If no server was running at 02:00, nothing ran at 02:00. What happens when one returns depends on RecurringJobOptions.MisfireHandling (MisfireHandlingMode.cs):
| Mode | What the scheduler creates after downtime |
|---|---|
Relaxed (default) |
One background job, however many occurrences were missed |
Strict |
One job per missed occurrence, with its scheduled time in the Time parameter |
Ignorable |
Nothing for missed occurrences |
Strict arrived in 1.8.0 and Ignorable in 1.8.1. By default, a job due at 02:00 on a host that returns at 06:10 runs once, shortly after 06:10. Catch-up counts from the entry's last execution, so it applies after restarts and deployments too, and an entry created after the missed time doesn't backfill. Ignorable is stricter than it sounds: it only creates runs the scheduler reaches within twice the polling interval of the due time, 30 seconds by default (RecurringJobEntity.cs).
When every occurrence matters, such as an hourly rollup, use Strict:
using Hangfire;
public static class UsageJobRegistration
{
public static void Register(IRecurringJobManager recurringJobs)
{
recurringJobs.AddOrUpdate<UsageJobs>(
"hourly-usage-rollup",
jobs => jobs.RollUpUsage(),
"0 * * * *",
new RecurringJobOptions { MisfireHandling = MisfireHandlingMode.Strict });
}
}
public class UsageJobs
{
public void RollUpUsage() => Console.WriteLine("Rolling up usage");
}Missed Strict runs are created together when the server returns, so the job should work out its period from its scheduled time, not the current clock.
9. It Ran, but Failed or Is Waiting to Retry#
A run that failed and is waiting to retry looks like a run that never happened: no output, no email, no report. Click the Last execution label and read the background job's state (job states):
| State | What it means for a recurring run |
|---|---|
| Enqueued | Waiting for a worker on its queue (cause 3) |
| Processing | A worker has it and hasn't finished |
| Scheduled | Waiting to retry, with a reason like "Retry attempt 3 of 10: …" |
| Failed | It threw, and retries are exhausted or turned off |
| Succeeded | It returned without throwing |
If every attempt fails with "Cannot access a disposed object," the job is reaching a service outside its own DI scope; see Hangfire dependency injection.
Hangfire's default AutomaticRetry filter retries up to 10 times with a delay that grows each attempt, so the tenth attempt comes more than four hours after the first failure (AutomaticRetryAttribute.cs). Retries don't hold up the schedule; the next occurrence is still created on time. Hangfire automatic retries covers tuning the policy.
Succeeded and deleted jobs expire after 24 hours by default, so a Last execution link that leads nowhere isn't evidence the run didn't happen. A run that stays in Processing is its own problem; see Hangfire job stuck in Processing.
Get Told When a Recurring Job Misses Its Schedule#
Every check above starts after someone noticed. Hangfire's dashboard lives inside the system that failed: when the server is asleep, missing, or pointed at another database, there's nothing for it to show, because nothing happened. Catching that takes a check outside the process, one that knows when each run is due and alerts when it doesn't arrive.
CronRadar does this for Hangfire with the CronRadar.Hangfire package. Store the CronRadar application key as CRONRADAR_API_KEY, install the package in the host that runs the Hangfire server, and add one call after AddHangfire():
dotnet add package CronRadar.Hangfireusing CronRadar.Hangfire;
using Hangfire;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHangfire(config => config
.UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")));
builder.Services.AddHangfireServer();
// Reads the application key from the CRONRADAR_API_KEY environment variable.
builder.Services.AddCronRadarHangfire();
var app = builder.Build();
app.Run();At startup the package reads Hangfire's recurring-job registry and declares each job and its cron expression to CronRadar. It reconciles the registry every 15 minutes by default through its own recurring job, cronradar:reconcile, which you'll see on the Recurring Jobs page. A server filter reports each execution's start, completion, or failure, so nothing goes inside your job methods, and monitoring errors are caught so they can't fail a job. Against the causes above:
- Missed: when no server creates or runs the job (causes 1, 2, 3, 7, and 8), no start arrives. After the expected time plus the grace period, the monitor goes from
warningtocriticaland alerts by email, Slack, webhook, Discord, or PagerDuty. The alert doesn't depend on the Hangfire server, so it fires even when the server is what's down. - Disappeared: when a later reconciliation no longer contains a recurring-job ID (cause 5), CronRadar pauses that monitor instead of deleting its history and marks it as no longer discovered.
- Stuck and failed: a start with no finish is flagged as stuck, and a reported failure alerts immediately (cause 9).
Every recurring job is monitored unless its method has [SkipMonitor]:
using CronRadar.Hangfire;
public class MaintenanceJobs
{
[SkipMonitor]
public void RebuildDevelopmentFixtures()
{
Console.WriteLine("Rebuilding development fixtures");
}
}Know the limits before relying on it:
- Retries: NuGet's current release is 0.1.0 (checked October 24, 2026). It can report a failure before Hangfire finishes retrying, including cancellations, so a job that later succeeds on retry can still alert. Reporting only after retries are exhausted requires the pending 0.2.1 release.
- Time zones: CronRadar evaluates schedules in UTC. For a job with a non-UTC
TimeZone, compare CronRadar's next expected run with Hangfire's Next execution before relying on missed-run alerts; if they differ, schedule that job in UTC or opt it out with[SkipMonitor]. - Scope: only recurring jobs are discovered, not one-time
EnqueueorSchedulejobs. A job never registered in the storage this host reads (cause 4) is never declared, so CronRadar can't expect it.
The Hangfire integration guide has the setup checklist and the current release notice. Use a separate CronRadar application and key per environment, so staging can't update production monitors.
Frequently Asked Questions#
Why does my Hangfire recurring job only run when I open the site?#
Under IIS, the app and its Hangfire server don't start until the first request, and the idle timeout and pool recycling stop them again. While the server is stopped, no recurring jobs are created. When a visit wakes the app, the default Relaxed misfire handling creates one catch-up run, so the job appears to run on visits. Set the app pool to Always Running with preload and application initialization, turn on Always On in Azure App Service, or run the server in a worker service.
Does Hangfire run missed recurring jobs after a restart?#
By default, once. MisfireHandlingMode.Relaxed creates a single background job when the scheduler next runs, however many occurrences were missed. Strict creates one per missed occurrence, and Ignorable, added in Hangfire 1.8.1, creates none. Catch-up counts from the entry's last execution, so an entry created after the missed time doesn't backfill.
Why does the dashboard show the next execution at the wrong time?#
Hangfire evaluates the cron expression in UTC unless the entry has a TimeZone, so 0 9 * * * registered without one means 09:00 UTC. The dashboard shows the next execution as a relative time, and its tooltip converts it to your browser's time zone. Set RecurringJobOptions.TimeZone when you mean a local time; Hangfire cron expressions and time zones covers daylight saving time.
How do I trigger a recurring job manually to test it?#
On the Recurring Jobs page, select the entry and click Trigger now. In code, call TriggerJob("nightly-report") on IRecurringJobManagerV2 from dependency injection, or the static RecurringJob.TriggerJob; both return the new background job's ID, or null if no entry has that ID. The triggered job goes to the entry's queue, so it tests the server, queue, and job code but not the schedule, and the next run still follows the cron expression.
How do I get alerted when a recurring job doesn't run?#
Use a check that lives outside Hangfire: it knows when each run is due and alerts when none reports by then, which still works when the Hangfire server is down. CronRadar.Hangfire builds those checks from the recurring-job registry with one AddCronRadarHangfire() call and alerts on missed, failed, and stuck runs. The current 0.1.0 package can report a failure before Hangfire finishes retrying; reporting only after retries are exhausted requires the pending 0.2.1 release.
Put this guide to work
Connect your Hangfire job to monitoring
Follow the current integration guide, check the release limitations, and verify a real run and alert delivery.
NuGet currently installs CronRadar.Hangfire 0.1.0. Setup and discovery work, but this release can report failures before Hangfire finishes retrying, including cancellations. Reporting only after retries are exhausted requires the pending 0.2.1 release. Every reconciliation sets each discovered monitor's grace period to CronRadar.CronRadar.DefaultGracePeriodSeconds, 60 unless set in code, replacing dashboard edits.