# Hangfire Cron Expressions: Syntax, Time Zones, and DST

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-31
Canonical: https://cronradar.com/blog/hangfire-cron-time-zones

> Hangfire 1.8 cron expression reference: five and six-field syntax, Cronos L, W, and #, the Cron helper, RecurringJobOptions.TimeZone, and daylight saving rules.

**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](https://github.com/HangfireIO/Cronos/blob/v0.11.1/README.md), 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](https://www.nuget.org/packages/Hangfire.Core/1.8.25) (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](https://cronradar.com/blog/cron-expression); for the Quartz.NET equivalent of this guide, see [Quartz.NET cron expressions](https://cronradar.com/blog/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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/RecurringJobManager.cs)).

```text
┌───────────── 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](https://github.com/HangfireIO/Cronos/blob/v0.11.1/README.md) matter most:

- When both day of month and day of week are restricted, a date must match **both**. `0 9 13 * FRI` runs 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 `MONDAY` and `SEPTEMBER` are rejected.
- Reversed ranges wrap around: `22-2` in 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/psake-project.ps1)), and [1.8.22](https://github.com/HangfireIO/Hangfire/releases/tag/v1.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](https://github.com/HangfireIO/Cronos/blob/v0.11.1/tests/Cronos.Tests/CronExpressionFacts.cs) 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Cron.cs) 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.TimeZone` set to New York, `Cron.Daily(2, 30)` runs at 02:30 New York time.
- **Arguments aren't validated.** `Cron.Daily(25)` returns `0 25 * * *`, and `AddOrUpdate` then 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](https://docs.cronradar.com/schedules) 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`](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/RecurringJobOptions.cs) 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:

```bash
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer
dotnet add package Microsoft.Data.SqlClient
```

```csharp
using 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/RecurringJob.cs), [RecurringJobManagerExtensions.cs](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/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](https://docs.hangfire.io/en/latest/upgrade-guides/upgrading-to-hangfire-1.8.html) 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`](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/ITimeZoneResolver.cs). 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](https://learn.microsoft.com/en-us/dotnet/api/system.timezoneinfo.findsystemtimezonebyid) supports IANA IDs on Windows starting with .NET 6, and [the .NET 6 announcement](https://devblogs.microsoft.com/dotnet/date-time-and-time-zone-enhancements-in-net-6/) says lookup converts to the other format when an ID isn't found. [Globalization and ICU](https://learn.microsoft.com/en-us/dotnet/core/extensions/globalization-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](https://github.com/dotnet/runtime/blob/v8.0.0/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.FullGlobalizationData.cs) skips the conversion in either mode. The [.NET 10 Alpine runtime-deps image](https://github.com/dotnet/dotnet-docker/blob/main/src/runtime-deps/10.0/alpine3.24/amd64/Dockerfile) 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/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 `ITimeZoneResolver` as 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();`:

```csharp
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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Dashboard/Pages/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](https://cronradar.com/blog/hangfire-recurring-job-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](https://github.com/HangfireIO/Cronos/blob/v0.11.1/tests/Cronos.Tests/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](https://docs.cronradar.com/integrations/hangfire) 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:

```bash
dotnet add package CronRadar.Hangfire
```

```csharp
using 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](https://docs.cronradar.com/schedules) 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:

```csharp
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](https://cronradar.com/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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/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.
