Hangfire Cron Expressions: Syntax, Time Zones, and DST
Updated
Contents
Hangfire 1.8 takes a standard five-field cron expression, or six fields with seconds first, and evaluates it in UTC unless you pass RecurringJobOptions.TimeZone. Every 5 minutes is */5 * * * *, and Cron.Daily(2, 30) is just the string 30 2 * * *. The parsing is done by Cronos, which adds L, W, #, and ? and decides what happens when daylight saving time skips or repeats an hour. The time zone you pass is saved as an ID string and looked up again by each server, so a Windows ID and an IANA ID can behave differently from one host to the next.
This guide covers Hangfire.Core 1.8.25, the current release on NuGet (checked October 24, 2026), which has Cronos 0.11.1 compiled in. For cron syntax in general and how other schedulers differ, see cron expression syntax; for the Quartz.NET equivalent of this guide, see Quartz.NET cron expressions.
Hangfire Accepts Five or Six Fields, Parsed by Cronos#
Hangfire splits the expression on spaces and tabs before handing it to Cronos (RecurringJobEntity.cs):
- Five fields are minute, hour, day of month, month, and day of week.
- With six fields, the first one is seconds.
- Any other count is rejected: "you can only use 5 or 6 (with seconds) part-based expressions."
- A string that starts with
@goes to Cronos as a macro.
AddOrUpdate parses the expression when you call it. An invalid one throws ArgumentException ("CRON expression is invalid") with the Cronos error as the inner exception, so a typo fails at registration instead of at 02:30 (RecurringJobManager.cs).
┌───────────── second (optional; present only in six-field expressions)
│ ┌───────────── minute (0-59)
│ │ ┌───────────── hour (0-23)
│ │ │ ┌───────────── day of month (1-31; also L, W, ?)
│ │ │ │ ┌───────────── month (1-12 or JAN-DEC)
│ │ │ │ │ ┌───────────── day of week (0-7 or SUN-SAT; 0 and 7 are Sunday; also L, #, ?)
│ │ │ │ │ │
0 30 2 * * *Cronos follows Linux cron closely. Three points from its README matter most:
- When both day of month and day of week are restricted, a date must match both.
0 9 13 * FRIruns only on Friday the 13th. Linux cron would run it on every 13th and every Friday. - Names are the first three letters, in any case. Full names such as
MONDAYandSEPTEMBERare rejected. - Reversed ranges wrap around:
22-2in the hour field means 22, 23, 0, 1, and 2.
Hangfire.Core doesn't install the Cronos NuGet package. Its build merges Cronos into Hangfire.Core.dll as internal types (psake-project.ps1), and 1.8.22 moved that internal copy to 0.11.1. Adding the standalone Cronos package (0.13.0, checked October 24, 2026) to your project doesn't change how Hangfire parses.
Extended Characters: L, W, #, and ?#
| Expression | Runs at |
|---|---|
0 23 L * * |
23:00 on the last day of every month |
0 23 L-2 * * |
23:00 two days before the last day (Feb 26 in 2027) |
0 9 15W * * |
09:00 on the weekday nearest the 15th |
0 18 LW * * |
18:00 on the last weekday of the month |
0 9 * * 5L |
09:00 on the last Friday of the month (FRIL also works) |
0 9 * * MON#1 |
09:00 on the first Monday of the month |
0 0 * * 6#3 |
00:00 on the third Saturday of the month |
0 9 ? * MON-FRI |
09:00 on weekdays; ? is a synonym for * |
W takes a single day number, not a range, list, or step. It moves a Saturday to Friday and a Sunday to Monday, but never into another month. The Cronos test suite shows 1W running on Monday, April 3, 2017, because April 1 was a Saturday and March 31 belongs to another month. 31W runs only in months that have a 31st. # accepts 1 through 5, and a fifth occurrence such as SUN#5 runs only in months that have one.
Cronos macros also pass straight through Hangfire: @every_second, @every_minute, @hourly, @daily, @midnight, @weekly, @monthly, @yearly, and @annually. Watch @weekly: it means Sunday at midnight, while Cron.Weekly() means Monday at midnight.
The Cron Helper Class Only Builds Strings#
Every method on Hangfire's Cron class returns a five-field string:
| Method | Returns |
|---|---|
Cron.Minutely() |
* * * * * |
Cron.Hourly() |
0 * * * * |
Cron.Hourly(15) |
15 * * * * |
Cron.Daily() |
0 0 * * * |
Cron.Daily(2, 30) |
30 2 * * * |
Cron.Weekly() |
0 0 * * 1 |
Cron.Weekly(DayOfWeek.Friday, 17, 30) |
30 17 * * 5 |
Cron.Monthly() |
0 0 1 * * |
Cron.Monthly(15, 6) |
0 6 15 * * |
Cron.Yearly() |
0 0 1 1 * |
Cron.Never() |
0 0 31 2 * |
Cron.MinuteInterval(5) |
*/5 * * * * |
Cron.HourInterval(6) |
0 */6 * * * |
Cron.DayInterval(10) |
0 0 */10 * * |
Cron.MonthInterval(3) |
0 0 1 */3 * |
A few things the source makes clear:
- The "UTC" in the XML docs is only the default. The string carries no zone. With
RecurringJobOptions.TimeZoneset to New York,Cron.Daily(2, 30)runs at 02:30 New York time. - Arguments aren't validated.
Cron.Daily(25)returns0 25 * * *, andAddOrUpdatethen rejects it. - The interval methods were obsolete until 1.8.22. Through 1.8.21 they carried "Please use Cron expressions instead"; 1.8.22 removed that and added remarks that only intervals dividing the range evenly work.
Cron.MinuteInterval(7)produces*/7 * * * *, which runs at :56 and then again at :00. Cron.Never()isn't obsolete. February 31 has no occurrences, so Hangfire stores no next execution, and the dashboard labels the job Disabled with the tooltip "Cron expression is invalid or doesn't have any occurrences over the next 100 years." You can still trigger it by hand.
Common Hangfire Cron Expressions#
Each expression runs in the job's time zone, which is UTC unless you set one. The last column says whether CronRadar's schedule syntax accepts the same string.
| Schedule | Expression | Cron helper | CronRadar accepts |
|---|---|---|---|
| Every 5 minutes | */5 * * * * |
Cron.MinuteInterval(5) |
Yes |
| Hourly at :15 | 15 * * * * |
Cron.Hourly(15) |
Yes |
| Daily at 02:30 | 30 2 * * * |
Cron.Daily(2, 30) |
Yes |
| Weekdays at 09:00 | 0 9 * * 1-5 |
None | Yes |
| First day of the month at 00:00 | 0 0 1 * * |
Cron.Monthly() |
Yes |
| Last day of the month at 23:00 | 0 23 L * * |
None | No (L) |
| First Monday of the month at 09:00 | 0 9 1-7 * MON |
None | Yes |
| Every 30 seconds | */30 * * * * * |
None | No (seconds) |
0 9 1-7 * MON means the first Monday in Hangfire because Cronos requires both day fields to match, and CronRadar reads it the same way. In Linux cron it would run on the 1st through the 7th and on every Monday. 0 9 * * MON#1 is the Cronos-only way to write the same thing.
Set the Time Zone With RecurringJobOptions#
The RecurringJobOptions constructor sets TimeZone to TimeZoneInfo.Utc and MisfireHandling to Relaxed. A job registered without options stores the zone ID UTC, which resolves on every host.
This Program.cs registers one UTC job and one New York job. It uses SQL Server storage:
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer
dotnet add package Microsoft.Data.SqlClientusing Hangfire;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHangfire(config => config
.UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")));
builder.Services.AddHangfireServer();
var app = builder.Build();
var recurringJobs = app.Services.GetRequiredService<IRecurringJobManager>();
// No options: evaluated in UTC, so this runs at 02:30 UTC every day.
recurringJobs.AddOrUpdate<ReportJobs>(
"nightly-report",
jobs => jobs.BuildNightlyReport(),
Cron.Daily(2, 30));
// Evaluated in New York time: 09:00 local on weekdays, through DST changes.
recurringJobs.AddOrUpdate<ReportJobs>(
"weekday-digest",
jobs => jobs.SendWeekdayDigest(),
"0 9 * * MON-FRI",
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/New_York")
});
app.Run();
public class ReportJobs
{
public void BuildNightlyReport() => Console.WriteLine("Building the nightly report");
public void SendWeekdayDigest() => Console.WriteLine("Sending the weekday digest");
}The static RecurringJob class and the IRecurringJobManager extension methods have the same overload shapes in 1.8.25 (RecurringJob.cs, RecurringJobManagerExtensions.cs). The method call can be Action, Action<T>, Func<Task>, or Func<T, Task>, and the cron argument can be a string or a Func<string> such as Cron.Daily.
| Overload shape | Status in 1.8.25 |
|---|---|
AddOrUpdate(id, methodCall, cron) |
Current; uses UTC |
AddOrUpdate(id, methodCall, cron, options) |
Current |
AddOrUpdate(id, queue, methodCall, cron) with or without options |
Current; throws NotSupportedException if storage lacks per-job queues |
AddOrUpdate(id, methodCall, cron, timeZone: null, queue: "default") |
Obsolete, removal planned for 2.0.0 |
| Any overload without a recurring-job ID | Obsolete, removal planned for 2.0.0 |
RecurringJobOptions.QueueName |
Obsolete; pass queue instead |
The 1.8 upgrade guide shows TimeZone = TimeZoneInfo.Local in its example. Be careful with that on more than one server. Every AddOrUpdate call compares the zone ID with the stored one and overwrites it when they differ, so the stored zone becomes the local zone of whichever host registered the job last.
Windows and IANA Time Zone IDs on Different Hosts#
Hangfire saves only options.TimeZone.Id. Each server's scheduler, and the dashboard, pass that string to an ITimeZoneResolver. The default, DefaultTimeZoneResolver, is a single call to TimeZoneInfo.FindSystemTimeZoneById, so Hangfire does no ID mapping of its own. Whether America/New_York or Eastern Standard Time resolves depends on the .NET runtime on that host:
| Host | America/New_York |
Eastern Standard Time |
|---|---|---|
| .NET Framework 4.x on Windows | Not found | Found |
| .NET 6 or later on Windows, ICU loaded | Found | Found |
| .NET 6 or later on Linux or macOS, tzdata and ICU | Found | Found |
| Linux, globalization-invariant mode, tzdata installed | Found | Not found |
| Official .NET Alpine image (invariant mode, no tzdata) | Not found | Not found |
The sources: FindSystemTimeZoneById supports IANA IDs on Windows starting with .NET 6, and the .NET 6 announcement says lookup converts to the other format when an ID isn't found. Globalization and ICU says the conversion isn't available in NLS mode or invariant mode, and that .NET 7 and later load ICU on Windows 10 version 1703 and Windows Server 2019 or later. The runtime source skips the conversion in either mode. The .NET 10 Alpine runtime-deps image sets DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true and installs neither ICU nor tzdata; the -extra variant installs both (checked October 24, 2026).
When a server can't resolve the stored ID, it can't compute the next run. Hangfire 1.8.25 logs a warning and retries at the scheduler's polling interval, 15 seconds by default. After five failed retries it disables the job: the next execution is cleared, the error is stored, and the dashboard shows Error (RecurringJobScheduler.cs). Once the host is fixed, calling AddOrUpdate again reschedules a job that has an error. Your own startup code fails earlier: FindSystemTimeZoneById throws TimeZoneNotFoundException on a host that can't resolve the ID.
Pick one ID format for the whole fleet:
- If every Hangfire server runs .NET 6 or later, use IANA IDs and make sure Linux images include tzdata. Add ICU if anything sends Windows IDs.
- If any server still runs .NET Framework, use Windows IDs. .NET Framework resolves only those, and .NET 6 or later on Linux converts them through ICU.
- To map IDs yourself, register your own
ITimeZoneResolveras a singleton. Hangfire's ASP.NET Core integration uses the registered resolver for the server, the recurring-job manager, and the dashboard.
To see what each job actually stored, add using Hangfire.Storage; at the top of the Program.cs above and add this after var app = builder.Build();:
var storage = app.Services.GetRequiredService<JobStorage>();
using (var connection = storage.GetConnection())
{
foreach (var job in connection.GetRecurringJobs())
{
Console.WriteLine(
$"{job.Id}: cron='{job.Cron}' zone={job.TimeZoneId ?? "UTC"} " +
$"next={job.NextExecution?.ToString("u") ?? "none"} error={job.Error ?? "none"}");
}
}NextExecution is stored in UTC whatever the job's zone, so it is the value to compare with any external system.
What the Dashboard Shows#
On the Recurring Jobs page (RecurringJobsPage.cshtml):
- Cron shows the stored expression. An invalid one gets a warning icon with the parse error. Only .NET Framework builds add an English description on hover.
- Time zone shows the stored ID as text, or UTC when none is stored. Hovering shows the zone's display name as resolved on the dashboard's host; if that host can't resolve the ID, a warning icon carries the exception message.
- Next execution shows relative time, such as "in 3 hours". The dashboard script renders the hover tooltip in the viewer's browser time zone, not the job's zone. A job with no next execution shows Error when a scheduling error is stored and Disabled otherwise.
If a job shows Error or Disabled, or is registered but never runs, see why a Hangfire recurring job is not running.
Daylight Saving Time: What Cronos Does With Skipped and Repeated Hours#
Cronos handles transitions like Vixie cron, and its tests pin down the details for US Eastern time (CronExpressionFacts.cs):
- Skipped time (spring forward): a run scheduled inside the missing hour moves to the first valid instant after the gap, 03:00 in New York. Occurrences that fall inside the gap become that single run.
- Repeated time (fall back): Cronos calls an expression interval-based when its second, minute, or hour field contains
*, a range, or a step. Interval-based expressions run in both passes through the repeated hour. Other expressions run once, in the first pass.
For a job with TimeZone set to America/New_York, the next two transitions look like this. Clocks go back from 02:00 EDT to 01:00 EST on November 1, 2026, and jump from 02:00 EST to 03:00 EDT on March 14, 2027.
| Expression | Date | Local run | UTC run |
|---|---|---|---|
30 1 * * * |
October 31, 2026 | 01:30 EDT | 05:30 |
30 1 * * * |
November 1, 2026 | 01:30 EDT only | 05:30 |
30 1 * * * |
November 2, 2026 | 01:30 EST | 06:30 |
15 * * * * |
November 1, 2026 | 01:15 EDT and 01:15 EST | 05:15 and 06:15 |
30 2 * * * |
March 13, 2027 | 02:30 EST | 07:30 |
30 2 * * * |
March 14, 2027 | 03:00 EDT (02:30 doesn't exist) | 07:00 |
30 2 * * * |
March 15, 2027 | 02:30 EDT | 06:30 |
*/30 * * * * |
March 14, 2027 | 01:30 EST, then 03:00 EDT | 06:30, 07:00 |
Daily runs are never skipped or doubled, but the time between them shrinks or grows by up to an hour, and the UTC time of every local-zone job moves twice a year.
A UTC job has the opposite effect. 0 13 * * * without a time zone runs at 13:00 UTC all year, which is 09:00 in New York in July and 08:00 in January. Check this first when a Hangfire job seems to run an hour off after the clocks change. If the run has to follow local business hours, set TimeZone. If it has to stay a fixed interval apart, keep UTC.
Keep Monitored Hangfire Jobs on UTC#
A valid expression proves only that Hangfire will try. The server can be stopped, a deployment can drop a registration, or a zone can fail to resolve and leave the job disabled, and none of that raises an exception in your code. CronRadar watches for the expected run from outside the app. For Hangfire, the CronRadar.Hangfire integration reads the recurring-job registry at startup and every 15 minutes by default, declares each job's schedule, and reports start, completion, and failure from a server filter. Add it after your existing setup:
dotnet add package CronRadar.Hangfireusing CronRadar.Hangfire;
// After builder.Services.AddHangfire(...) and AddHangfireServer().
builder.Services.AddCronRadarHangfire();Set CRONRADAR_API_KEY in the host's environment, and put [SkipMonitor] on any job method you don't want monitored. Package status checked September 20, 2026: the published CronRadar.Hangfire 0.1.0 can report a failure before Hangfire finishes retrying, including cancellations. Reporting only after retries are exhausted requires the pending 0.2.1 release.
The schedule rules on CronRadar's side decide how you write the Hangfire expression. CronRadar evaluates every schedule in UTC and accepts only standard five-field expressions: no seconds field, no L, W, #, or ?, no shortcuts such as @daily, and no 7 for Sunday. Like Cronos, it requires a date to match both day fields when both are restricted.
Keep monitored recurring jobs on UTC, Hangfire's default. Then the expression in your code, Hangfire's NextExecution, and CronRadar's expectation all describe the same instant all year.
For a job that has to run in a local zone, compare the next expected run CronRadar shows with Hangfire's NextExecution before relying on missed-run alerts. Convert both to the same zone first, and check again after the next clock change. CronRadar's schedule docs cover a scheduler that follows daylight saving time: use the earlier of the two UTC times and a grace period of at least an hour plus the job's usual duration. That can't be applied to a job CronRadar.Hangfire 0.1.0 discovers: every reconciliation re-declares the job's own cron expression, which CronRadar reads as UTC, and a 60-second grace period, replacing dashboard edits, so a 02:00 New York job is expected at 02:00 UTC. Move the job to UTC, or give it [SkipMonitor], report it with the base SDK and 0 6 * * *, and set at least 3,600 seconds of grace on that monitor in the dashboard.
A Hangfire schedule with a seconds field, L, W, or # uses syntax CronRadar doesn't accept, and a sub-minute schedule or an L, W, or # rule has no five-field equivalent. Confirm in CronRadar's inventory that such a job was synchronized rather than assuming it. To bring an L, W, or # job under monitoring, schedule it on every candidate date and let the method decide, as the schedule docs recommend. Every invocation reports, including the ones that return early:
public class BillingJobs
{
// Registered with "0 23 28-31 * *" in UTC instead of "0 23 L * *".
public void CloseMonth()
{
var today = DateTime.UtcNow.Date;
if (today.AddDays(1).Month == today.Month)
{
return; // Not the last day of the month.
}
Console.WriteLine($"Closing billing month {today:yyyy-MM}");
}
}CronRadar for Hangfire summarizes what the integration covers.
Frequently Asked Questions#
What cron format does Hangfire use?#
Hangfire 1.8 accepts standard five-field cron expressions, or six fields with seconds first. Cronos 0.11.1, compiled into Hangfire.Core, does the parsing, so L, W, #, ?, reversed ranges, and macros such as @daily also work. When both day fields are restricted, a date must match both, unlike Linux cron.
How do I run a Hangfire job every 5 minutes?#
Use */5 * * * *, or Cron.MinuteInterval(5), which returns the same string. For example: RecurringJob.AddOrUpdate("sync-orders", () => Console.WriteLine("Syncing orders"), "*/5 * * * *");. The step counts from the top of the hour, so runs land on :00, :05, :10, and so on, not five minutes after registration.
What time zone do Hangfire recurring jobs use?#
UTC, unless you pass new RecurringJobOptions { TimeZone = ... } to AddOrUpdate. Hangfire stores the zone's ID string and every server resolves it with TimeZoneInfo.FindSystemTimeZoneById. Use an ID format that all of your hosts can resolve. NextExecution is always stored in UTC.
Why does my Hangfire job run an hour off after daylight saving changes?#
If the job has no time zone, it runs at a fixed UTC time, so its local wall-clock time moves by an hour when the clocks change. If it has a local zone, its local time stays put and its UTC time moves instead, which can confuse anything that expects UTC. Set TimeZone when the job must follow local hours, and keep UTC when it must stay evenly spaced.
Can Hangfire run a job every 30 seconds?#
Yes, with a six-field expression: */30 * * * * *. In 1.8.25 the recurring-job scheduler checks for due jobs every SchedulePollingInterval, 15 seconds by default (BackgroundJobServer.cs), so each run can be enqueued up to that long after its nominal time and then waits for a free worker. CronRadar accepts only five-field schedules, whose smallest unit is a minute, so a 30-second job has no CronRadar equivalent.
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.