Hangfire Recurring Jobs Not Running: Root Cause and Fix

Hangfire recurring jobs stopping silently in production is one of those issues that looks mysterious until you have seen the same failure pattern a few times. The scheduler appears healthy in the dashboard, the cron expression looks right, and yet jobs simply never fire. In production, I have seen this derail release timelines more than once - the real causes are almost always one of a small set of root causes, and each has a clear fix.
If you want the complete Hangfire production setup - server configuration, job registration, queue management, and the Outbox pattern wired together inside a real API - Chapter 12 of the Zero to Production course walks through it end to end against a working ASP.NET Core codebase you can run immediately.
The patterns in this post come from real shipping codebases. For annotated, production-ready source code covering these and other background job scenarios, Patreon has the full implementations ready to run and adapt - including edge cases that rarely make it into blog posts.
The Problem
Recurring jobs in Hangfire are not executed on a timer inside your application code. Instead, Hangfire uses a dedicated background component - the Hangfire Server - that polls persistent storage on a schedule (approximately once per minute) and enqueues due jobs for processing.
When a recurring job stops firing, the failure is almost never in the job logic itself. It is in the infrastructure driving it: the server, how the job was registered, the deployment environment, or a serialization failure that gets swallowed silently.
Why Hangfire Recurring Jobs Stop Firing
1. The Hangfire Server Is Not Running
This is the most common cause and the easiest to miss. The Hangfire dashboard can display your recurring jobs perfectly - because the dashboard reads only from persistent storage. But the Hangfire Server - the component that checks which recurring jobs are due and enqueues them - needs to be explicitly started.
// Missing AddHangfireServer - jobs will never fire
builder.Services.AddHangfire(config =>
config.UseSqlServerStorage(connectionString));
// Correct - both registrations are required
builder.Services.AddHangfire(config =>
config.UseSqlServerStorage(connectionString));
builder.Services.AddHangfireServer();
AddHangfireServer() registers the in-process Hangfire Server as an IHostedService. Without it, no scheduling or processing happens regardless of what the dashboard shows.
2. Job Registration Is Being Overwritten Silently
RecurringJob.AddOrUpdate uses a string job ID as the key. If you call it more than once with the same ID - or if a second registration accidentally uses the same ID with a different cron expression - the later call silently overwrites the first. The dashboard shows the job with the last-registered cron expression, and the original schedule is gone.
A related cause: calling AddOrUpdate during startup before the DI container is fully built. This can result in partial service resolution failures that prevent registration from completing - with no obvious error logged.
Rule: consolidate all RecurringJob.AddOrUpdate calls in one place, called from an IHostedService.StartAsync or IApplicationLifetime.ApplicationStarted handler, so they run once the full host is up.
3. IIS App Pool Recycling Kills the Server
If your ASP.NET Core app is hosted on IIS and the application pool recycles - due to idle timeout, a scheduled recycle, or memory pressure - the Hangfire Server IHostedService shuts down with it. When the next HTTP request restarts the app, Hangfire Server registration re-runs. But if the pool idles again before the next job is due, the server shuts down again.
In production I have seen this cause a 12-hour window where no recurring jobs fired, with the dashboard showing jobs as registered and "next execution: in 5 minutes" indefinitely as each scheduled execution slipped past an idle-terminated server.
Fixes to apply together:
Disable the IIS application pool idle timeout (set to 0)
Enable "Always Running" start mode via the Application Initialization module
See Hangfire's IIS deployment guide for the exact settings
If you are on containers or a Linux systemd deployment rather than IIS, this cause does not apply - but verify your container orchestrator is not cycling the process under load.
4. Workers Are Not Listening to the Job's Queue
Hangfire routes jobs to named queues. The default queue is "default". If you register a recurring job targeting a custom queue, the Hangfire Server must be configured to listen to that queue - otherwise the job enqueues but never gets processed.
// Job registered to a custom queue
RecurringJob.AddOrUpdate<IReportService>(
"daily-report",
x => x.GenerateAsync(),
Cron.Daily,
new RecurringJobOptions { QueueName = "reports" });
// Server must include that queue in its listen list
builder.Services.AddHangfireServer(options =>
{
options.Queues = new[] { "reports", "default" };
});
Check the dashboard's Enqueued view. If jobs are building up in a queue with a worker count of zero, this is the cause.
5. The Cron Expression Is Correct But the Time Zone Is Wrong
Hangfire evaluates cron expressions in UTC by default. If your schedule is intended in local time - for example, "0 9 * * *" to fire at 9:00 AM UAE time - it fires at 9:00 AM UTC instead (1:00 PM Dubai time in summer). The job is running; it just fires at a different time than expected.
// Specify time zone explicitly to avoid UTC confusion
RecurringJob.AddOrUpdate<IReportService>(
"daily-report",
x => x.GenerateAsync(),
"0 9 * * *",
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Dubai")
});
Always pass RecurringJobOptions.TimeZone explicitly when the cron schedule is meant in local time. The dashboard shows the next execution in the job's configured time zone once you inspect the job record directly.
6. Silent Serialization Failures Swallowing Jobs
Hangfire serializes job arguments to JSON before storing them in the persistent store. A version mismatch between Hangfire NuGet packages (Hangfire.Core, Hangfire.SqlServer, Hangfire.AspNetCore), or a change to the custom serializer configuration, can cause deserialization to fail when the job is dequeued - silently in some server configurations.
The failure moves the job to the Failed state without surfacing as an exception in your application logs. The only place you see it is the dashboard's Failed tab.
<!-- Pin all Hangfire packages to the same version range -->
<PackageReference Include="Hangfire.Core" Version="1.8.*" />
<PackageReference Include="Hangfire.SqlServer" Version="1.8.*" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.*" />
Using a wildcard minor version (1.8.*) ensures they stay in sync on every restore and upgrade.
How to Diagnose in Under Five Minutes
Dashboard - Servers tab: is the server listed with an active heartbeat? If empty or stale,
AddHangfireServer()is missing or the host is downDashboard - Recurring Jobs tab: is the job listed? Is the "Next execution" timestamp correct for your intended time zone?
Dashboard - Enqueued tab: are jobs queued with zero workers consuming them? Queue name mismatch
Dashboard - Failed tab: are there failed execution attempts? The exception detail reveals serialization or DI failures
Application startup logs: search for
Hangfirelog output - a healthy startup logs the server ID, queue names, and worker countIIS Application Pool settings (IIS only): check idle timeout and start mode
How to Prevent Recurrence
Once the immediate fix is in place, three practices prevent these issues from coming back:
Centralise job registration. Keep all RecurringJob.AddOrUpdate calls in one place - an IHostedService that runs after the host fully starts. Scattered registration across Program.cs and multiple startup hooks is how duplicate registrations and ordering bugs happen.
Wire Hangfire into your health check endpoint. Hangfire.AspNetCore 1.8+ exposes a health check you can add via AddHangfireServer options and expose at /health/ready. If the server goes down, the health check fails and your alerting catches it before jobs start missing.
Alert on failed job count. Export the Hangfire failed job count as a metric via a lightweight background probe and alert when it rises above zero. A silent serialization failure that fails 100 jobs in a row will not surface in your API error rate - but it will in this metric.
For the full picture of ASP.NET Core background service failure modes - including IHostedService lifecycle issues, scope management pitfalls, and System.Threading.Channels for in-process queuing - 7 Common ASP.NET Core BackgroundService Mistakes is worth reading alongside this.
If you are also studying for interviews or want a deeper conceptual grounding in how Hangfire, IHostedService, and BackgroundService fit the .NET host model, ASP.NET Core Background Services Interview Questions for Senior .NET Developers (2026) covers the lifecycle, failure modes, and common trade-offs in an exam-prep format.
FAQ
Why Does the Hangfire Dashboard Show My Recurring Job But It Never Runs?
The dashboard reads directly from persistent storage and reflects whatever jobs are registered there. The processing of those jobs requires an active Hangfire Server started via AddHangfireServer(). If the server is not running - because it was never registered, because IIS recycled the app pool, or because the host process crashed - the dashboard continues showing the job as scheduled while nothing executes it.
How Do I Check If Hangfire Server Is Running in Production?
Open the Hangfire dashboard and navigate to the Servers tab. A healthy server shows its server ID, the queues it is listening to, the worker count, and a recent heartbeat timestamp (updated every 5 seconds by default). An empty list or a stale heartbeat means the server has stopped.
Why Do Hangfire Recurring Jobs Fire at the Wrong Time?
Hangfire evaluates cron expressions in UTC by default. If your intended schedule is in a local time zone, pass RecurringJobOptions.TimeZone explicitly when calling AddOrUpdate - for example, TimeZoneInfo.FindSystemTimeZoneById("Asia/Dubai") for UAE time. The dashboard will then show the next execution in the configured time zone.
Can Hangfire Miss a Recurring Job If the Application Was Down When It Was Due?
Yes. Hangfire does not do catch-up execution for recurring jobs by default. If the server was down when a recurring job was due, the missed execution is skipped and the next execution is scheduled from the restart point. If you need guaranteed at-least-once execution across planned and unplanned downtime, pair Hangfire with an external trigger rather than relying solely on the in-process cron component.
Do I Need Both AddHangfire and AddHangfireServer in Program.cs?
Yes - always both. AddHangfire registers the storage backend and the dashboard infrastructure. AddHangfireServer registers the Hangfire Server as an IHostedService that actually polls for due jobs and processes them. Without AddHangfireServer, no jobs are ever dequeued or executed regardless of what is configured in storage.
About the Author
I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.
GitHub: codingdroplets
YouTube: Coding Droplets
Website: codingdroplets.com






