Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 046 / 100

Scheduled jobs: Hangfire vs Quartz in .NET & ASP.NET Core

Cron in .NET without reinventing it.

Expona Admin9 min read

Scheduled jobs: Hangfire vs Quartz—picking the right scheduler

After five years building distributed systems, I've reached for a job scheduler in almost every production platform I've worked on. Whether it's sending reminder emails, syncing inventory, or charging subscriptions, you'll eventually need a reliable way to run code on a schedule. The question isn't whether to use a scheduler—it's which one fits your constraints without becoming a maintenance burden.

In this post, I'm comparing the two most mature .NET options: Hangfire and Quartz.NET. Both are battle-tested in production, but they solve slightly different problems. Let's break down when to reach for each one, then dive into patterns that keep your scheduled jobs sane as your system grows.

Side-by-side: Hangfire vs Quartz.NET

Here's the honest truth: if you need a dashboard and want to start in 10 minutes, Hangfire wins. If you need fine-grained scheduling rules, complex calendars, or deep clustering control, Quartz shines. Let me walk through the specifics.

Feature Hangfire Quartz.NET
Dashboard Built-in, included by default No UI; requires third-party dashboards
Job types Fire-and-forget, delayed, recurring Fire-and-forget, complex triggers, calendars
Storage SQL Server, PostgreSQL, Redis, SQLite ADO.NET (SQL Server, Oracle, MySQL, etc.)
Setup time Minutes Hours (more configuration)
Control level Convenient defaults Fine-grained control over everything
Clustering Built-in support Built-in support
Learning curve Shallow Steeper

I started with Hangfire on a CRM platform where speed mattered more than scheduling complexity. Dashboard access let the support team requeue failed jobs without calling us. Six months in, we never hit limitations—fire-and-forget jobs for notifications and recurring jobs for data sync just worked.

On a different project (ride-hailing platform), we needed Quartz because drivers could only be charged during specific hours, and scheduling laws required complex time-window rules. Quartz's calendar support and trigger chains made that tractable. But we built our own dashboard because the default experience is console logs and configuration files.

When to use Hangfire

  • You have straightforward recurring jobs (every hour, daily at 3 AM, every weekday).
  • You want a working dashboard without code.
  • Your team is small and ops burden matters.
  • You prefer Redis or SQL Server as storage.

When to use Quartz.NET

  • You need complex scheduling rules (lunar calendars, business day calendars, blackout windows).
  • Your team is comfortable with configuration and wants explicit control.
  • You're already invested in ADO.NET pools and Oracle or MySQL.
  • You need to inspect or modify scheduled triggers at runtime.

Idempotent jobs: the cardinal rule

Here's what I learned the hard way: your job will fail and retry. Your job might run twice in the same second during a deployment. Your job might run on two different nodes simultaneously if you misconfigure clustering. None of this is theoretical—I've debugged each scenario in production.

The answer is idempotency: design every job so running it twice produces the same result as running it once.

Let's say you're crediting user accounts for a promotion. The naive approach:

csharp

public class CreditPromotionJobHandler
{
    private readonly IUserRepository _users;
    private readonly IAccountService _accounts;

    public async Task Execute()
    {
        var users = await _users.GetActiveUsersAsync();
        foreach (var user in users)
        {
            await _accounts.CreditAsync(user.Id, 50); // $50
        }
    }
}

If this job runs twice, users get credited twice. That's a financial disaster waiting to happen.

The idempotent approach uses a ledger or flag:

csharp

public class CreditPromotionJobHandler
{
    private readonly IUserRepository _users;
    private readonly IAccountService _accounts;
    private readonly IPromotionAuditRepository _audit;

    public async Task Execute()
    {
        var promotionId = "promo-2024-jan";
        var alreadyRun = await _audit.ExistsAsync(promotionId);
        
        if (alreadyRun)
        {
            // Idempotent: skip if already processed
            return;
        }

        var users = await _users.GetActiveUsersAsync();
        foreach (var user in users)
        {
            await _accounts.CreditAsync(user.Id, 50);
        }

        // Record that we ran, atomically with the credits
        await _audit.RecordAsync(promotionId, DateTime.UtcNow);
    }
}

Or, if crediting is already idempotent (like "set balance to X" instead of "add X"), just make sure the job logic itself is stateless:

csharp

public class SyncInventoryJobHandler
{
    private readonly IInventoryService _inventory;
    private readonly IThirdPartyApi _api;

    public async Task Execute()
    {
        var remote = await _api.GetInventoryAsync();
        
        foreach (var item in remote)
        {
            // SetStock is idempotent: running twice sets the same value twice
            await _inventory.SetStockAsync(item.Sku, item.Quantity);
        }
    }
}

In a trading platform I worked on, we synced market data every 30 seconds. The job fetched the latest prices and updated our cache. If the job ran twice in one second, we'd just overwrite the cache with the same data—harmless. But if we'd been updating a position counter instead of setting it, we'd have bugs.

Think through the failure modes. If your job can't complete atomically, break it into smaller jobs or use a distributed lock (Redis SETNX, or a database row with an exclusive lock) to ensure only one instance runs at a time.

Keep jobs thin: delegation to application services

The scheduler framework shouldn't know your business logic. Your job class should be a thin wrapper that marshals parameters and delegates to your application layer.

Here's the pattern I use everywhere:

csharp

// In a travel booking system: send reminder emails
public class SendBookingRemindersJobHandler
{
    private readonly IBookingReminderService _reminderService;
    private readonly ILogger<SendBookingRemindersJobHandler> _logger;

    public SendBookingRemindersJobHandler(
        IBookingReminderService reminderService,
        ILogger<SendBookingRemindersJobHandler> logger)
    {
        _reminderService = reminderService;
        _logger = logger;
    }

    public async Task Execute()
    {
        try
        {
            // Job does: orchestration + error handling
            var sent = await _reminderService.SendRemindersForUpcomingBookingsAsync();
            _logger.LogInformation("Sent {count} booking reminders", sent);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Booking reminder job failed");
            throw; // Let the scheduler retry
        }
    }
}

// Business logic lives in a testable service
public class BookingReminderService : IBookingReminderService
{
    private readonly IBookingRepository _bookings;
    private readonly IEmailGateway _email;

    public async Task<int> SendRemindersForUpcomingBookingsAsync()
    {
        var tomorrow = DateTime.UtcNow.AddDays(1);
        var bookings = await _bookings.GetBookingsDueForReminderAsync(tomorrow);

        int sent = 0;
        foreach (var booking in bookings)
        {
            await _email.SendAsync(booking.UserEmail, 
                new BookingReminderTemplate(booking));
            sent++;
        }
        return sent;
    }
}

// In tests: no scheduler, just the service
[Test]
public async Task SendReminders_ShouldSendOneEmailPerBooking()
{
    var service = new BookingReminderService(
        _mockBookings,
        _mockEmail);

    int result = await service.SendRemindersForUpcomingBookingsAsync();

    Assert.That(result, Is.EqualTo(3));
    _mockEmail.Verify(e => e.SendAsync(It.IsAny<string>(), 
        It.IsAny<BookingReminderTemplate>()), Times.Exactly(3));
}

The job handler is five lines. All the logic is in BookingReminderService, which has zero scheduler dependencies. You test the service in isolation, mock the repositories and gateways, and ship with confidence.

This separation also makes rotating jobs easy: the same service can be called from an API endpoint, a message queue consumer, or a scheduled job. You're not locked into the scheduler framework.

Watch for clustering: two nodes, two job runs

Both Hangfire and Quartz support clustered deployments. You run the same job on multiple nodes, and they coordinate through shared storage (SQL or Redis) to ensure each scheduled job runs once.

The coordination works because of distributed locks. When a node acquires a lock on a job, other nodes back off. This is built-in and reliable if you configure it correctly.

But there are pitfalls:

Lock timeout misconfiguration: If your job takes 30 minutes but your lock timeout is 5 minutes, a second node will steal the lock and you'll run twice. Set timeouts conservatively—longer than your job's maximum execution time plus a safety margin.

Clock skew: In a Kubernetes cluster, node clocks can drift. Quartz is sensitive to this; Hangfire is more forgiving. Keep clocks synchronized via NTP, or use a cloud provider's managed clock service.

Storage connection pooling: If your clustered nodes share a database but the connection pool is too small, lock acquisitions can time out and jobs can run twice. On a ride-hailing platform, we used 50 job nodes on the same database; the connection pool needed to be tuned carefully.

Here's a Hangfire configuration for a clustered setup:

csharp

services.AddHangfire(config => config
    .UsePostgreSqlStorage("User Id=postgres;Host=db;Database=jobs;")
    .UseFilter(new AutomaticRetryAttribute { Attempts = 3 }));

services.AddHangfireServer(options =>
{
    options.ServerName = Environment.GetEnvironmentVariable("HOSTNAME");
    options.WorkerCount = 20;
    options.SchedulePollingInterval = TimeSpan.FromSeconds(15);
});

And a Quartz configuration:

csharp

services.Configure<QuartzOptions>(options =>
{
    options.Scheduling.IgnoreDuplicates = true;
    options.Scheduling.OverWriteExistingData = true;
});

services.AddQuartz(options =>
{
    options.UseMicrosoftDependencyInjectionJobFactory();
    options.UseDefaultThreadPool(tp => tp.MaxConcurrency = 10);
    options.UseJobStore(js =>
    {
        js.UsePostgres(c => c.ConnectionString = "...");
        js.UseJsonSerializer();
    });
});

With clustering enabled, test failover in staging. Kill a node mid-job and verify the job completes on another node, not duplicated. I've seen teams skip this and ship with data corruption bugs.

Common mistakes

  • Forgetting idempotency: Assuming the job runs exactly once. It won't. Always design for retries and concurrent runs.
  • Blocking the job thread: Long-running synchronous work in a job blocks the worker pool. Use async/await or offload to a background queue.
  • Storing state in the job handler: Job handlers are instantiated fresh each run. Static fields and instance variables don't persist across executions.
  • Skipping clustering setup in production: Running multiple instances without a shared storage lock will cause duplicate job runs and subtle data corruption.
  • Not logging enough context: When a job fails at 2 AM, you need to know which user, which booking, which retry attempt. Include correlation IDs and structured logging.
  • Hardcoding schedule values: Cron expressions and intervals should live in config, not source code. You'll want to adjust them without redeploying.

Key takeaways

  • Hangfire is the faster path to production if you need a dashboard and simple recurring jobs. Quartz offers finer control at the cost of setup.
  • Design every job to be idempotent—assume it will run multiple times and build logic that handles that gracefully.
  • Keep job handlers thin. Push business logic into testable application services that know nothing about the scheduler.
  • Enable clustering and lock your jobs when running multiple instances, or risk duplicate executions and data corruption.
  • Log aggressively. Include user IDs, resource counts, and retry context so you can diagnose failures without guessing.
  • Test failover scenarios in staging: kill nodes, simulate latency, verify jobs complete exactly once.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles