Recurring jobs are everywhere in real applications. Sending daily reports. Cleaning up expired data. Syncing external APIs. Running monthly billing.
You could solve this with BackgroundService and a Timer. But then you need to handle persistence, retries, monitoring, and distributed execution yourself.
Hangfire handles all of that. And with PostgreSQL as storage, you get a production-ready setup without depending on SQL Server or Redis.
In this article, I’ll show you how to schedule recurring jobs in .NET using Hangfire and PostgreSQL.
Installing Hangfire
Create a new Web API project and install these packages:
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.PostgreSql
Hangfire.AspNetCore brings the core library and ASP.NET Core integration. Hangfire.PostgreSql is the community-maintained storage provider for PostgreSQL.
Configuring Hangfire Services
Register Hangfire and the server in Program.cs:
builder.Services.AddHangfire(config =>
config.UsePostgreSqlStorage(options =>
options.UseNpgsqlConnection(
builder.Configuration.GetConnectionString("HangfireConnection"))));
builder.Services.AddHangfireServer();
AddHangfire registers the core services and points them to PostgreSQL. AddHangfireServer adds the background processor that executes your jobs.
Enabling the Dashboard
Expose the Hangfire Dashboard on the request pipeline:
app.UseHangfireDashboard("/hangfire");
On first run, Hangfire automatically creates its tables under the hangfire schema. No migrations required.
Job Types in Hangfire
Hangfire supports four job types:
// Fire-and-forget: runs once, immediately
BackgroundJob.Enqueue(() => Console.WriteLine("Run now"));
// Delayed: runs once, after a delay
BackgroundJob.Schedule(() => Console.WriteLine("Run later"), TimeSpan.FromMinutes(30));
// Recurring: runs on a schedule
RecurringJob.AddOrUpdate("job-id", () => Console.WriteLine("Run daily"), Cron.Daily);
// Continuation: runs after another job completes
var jobId = BackgroundJob.Enqueue(() => Console.WriteLine("First"));
BackgroundJob.ContinueJobWith(jobId, () => Console.WriteLine("Run after"));
This article focuses on recurring jobs.
Creating a Recurring Job
Let’s say you want to send a daily report at 7 AM. Create the service:
public class ReportService(ILogger<ReportService> logger)
{
public async Task SendDailyReportAsync()
{
logger.LogInformation("Sending daily report at {Time}", DateTime.UtcNow);
// Your logic here: query data, generate PDF, send email
await Task.CompletedTask;
}
}
Schedule the recurring job after UseHangfireDashboard:
RecurringJob.AddOrUpdate<ReportService>(
"daily-report",
service => service.SendDailyReportAsync(),
Cron.Daily(7));
That’s it. Hangfire will run SendDailyReportAsync every day at 7 AM.
Understanding AddOrUpdate
AddOrUpdate is idempotent. It creates the job if it doesn’t exist, or updates it if it does. You can safely call it on every application start without creating duplicates.
The parameters are:
- Job ID — unique identifier used in the Dashboard and storage.
- Expression — Hangfire serializes this and resolves the service from DI on execution.
- Cron expression — when to run.
Cron Expressions
Hangfire uses standard cron expressions with 5 fields: minute hour day month day-of-week.
| Expression | Meaning |
|---|---|
*/5 * * * * | Every 5 minutes |
0 * * * * | Every hour |
0 7 * * * | Every day at 7 AM |
0 0 * * 1 | Every Monday at midnight |
0 3 1 * * | First day of every month at 3 AM |
Hangfire also provides helpers for common schedules:
Cron.Minutely();
Cron.Hourly();
Cron.Daily(7); // every day at 7 AM
Cron.Weekly();
Cron.Monthly();
Using Time Zones
By default, recurring jobs run in UTC. If you need a specific time zone, pass it explicitly:
RecurringJob.AddOrUpdate<ReportService>(
"daily-report",
service => service.SendDailyReportAsync(),
"0 7 * * *",
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/New_York")
});
Now the job runs at 7 AM New York time, not UTC.
Retries and Concurrency
By default, Hangfire retries a failed job 10 times with exponential backoff. You can override this with attributes:
public class ReportService(ILogger<ReportService> logger)
{
[AutomaticRetry(Attempts = 3)]
[DisableConcurrentExecution(timeoutInSeconds: 300)]
public async Task SendDailyReportAsync()
{
// Retries up to 3 times
// Never runs concurrently with itself (waits up to 5 minutes for lock)
}
}
DisableConcurrentExecution is critical for jobs like billing or cleanup, where running twice simultaneously could cause data issues.
Securing the Dashboard
The Dashboard exposes sensitive information and lets users manipulate jobs. Never expose it unprotected in production.
Add an authorization filter:
public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
return httpContext.User.Identity?.IsAuthenticated == true &&
httpContext.User.IsInRole("Admin");
}
}
Then apply it to the Dashboard:
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[] { new HangfireAuthorizationFilter() }
});
Only authenticated admins can now access the Dashboard.
Takeaway
Hangfire gives you a reliable, persistent, and observable way to schedule recurring jobs in .NET.
The core pattern is simple:
- Configure Hangfire with UsePostgreSqlStorage.
- Register AddHangfireServer to process jobs.
- Call RecurringJob.AddOrUpdat
ewith a cron expression. - Secure the Dashboard before shipping to production.
Keep your jobs idempotent, pass IDs instead of full objects as parameters, and always set a time zone when the schedule matters to users.
That’s all you need to ship recurring jobs to production.



