Hangfire Recurring Jobs Not Running: Root Cause and Fix

Search for a command to run...

No comments yet. Be the first to comment.
The prompt is the leak. Every other control in an AI feature gets scrutinised - authentication on the endpoint, authorization on the tools, validation of the model's output - while the one thing that

Most of the .NET conversation about the Model Context Protocol is about building servers: expose your API as MCP tools, point Claude or VS Code at it, done. That is the half that gets written about. T

The first RAG system I shipped answered beautifully about concepts and failed completely on part numbers. Ask it "how do I reset a stuck deployment" and it nailed the answer. Ask it "what does error P

AutoMapper has been the default object mapper in .NET for over a decade, and for most of that time nobody thought about it. Version 15.0.0 changed that. It ships under a dual license now, with a free

You built the pipeline. Documents are chunked, embedded, and sitting in a vector store. Retrieval returns results. And your RAG answers still hallucinate in .NET, confidently telling users about a stu

Coding Droplets
305 posts
Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.
From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.
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.
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.
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.
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.
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.
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.
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.
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.
Dashboard - Servers tab: is the server listed with an active heartbeat? If empty or stale, AddHangfireServer() is missing or the host is down
Dashboard - 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 Hangfire log output - a healthy startup logs the server ID, queue names, and worker count
IIS Application Pool settings (IIS only): check idle timeout and start mode
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.
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.
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.
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.
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.
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.
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