Skip to content

From Hangfire

Move Hangfire fire-and-forget, delayed, recurring and continuation jobs to Immediate.Jobs.

4 min read

Hangfire records a method call, such as x => x.SendWelcome(userId), and replays it on a worker. Immediate.Jobs uses job classes with typed payloads instead. Most of the migration is turning each method you enqueue into a job class. Its callers then use that job’s generated scheduler.

Registration

Before: Hangfire
builder.Services.AddHangfire(config => config
	.UseSqlServerStorage(connectionString));
builder.Services.AddHangfireServer(options =>
{
	options.WorkerCount = 16;
	options.Queues = ["critical", "default"];
});

app.UseHangfireDashboard("/hangfire");
After: Immediate.Jobs
builder.Services.AddMyAppHandlers();
builder.Services.AddMyAppJobs()
	.ConfigureWorkers(options => options.WorkerCount = 16)
	.ConfigureStorage(storage => storage
		.UseEntityFrameworkCore<JobsDbContext>()
		.UseDistributed())
	.AddImmediateJobsDashboard()
	.ConfigureDashboard(options => options.AuthorizationPolicy = "operations")
	.AddHealthCheck();

app.MapImmediateJobsDashboard("/jobs");

Queues are declared once with [QueueDefinition], and each job opts in with [UsesQueue<T>], so there is no queue list to keep in sync. UseDistributed() matches Hangfire’s model of several servers sharing one database. See Configuring storage providers for the EF Core JobsDbContext and its migration.

Fire-and-forget and delayed jobs

Before: Hangfire
public sealed class EmailService(IEmailSender sender)
{
	[AutomaticRetry(Attempts = 5)]
	[Queue("critical")]
	public Task SendWelcome(Guid userId, string template) =>
		sender.SendAsync(userId, template, CancellationToken.None);
}

BackgroundJob.Enqueue<EmailService>(x => x.SendWelcome(userId, "v2"));
BackgroundJob.Schedule<EmailService>(x => x.SendWelcome(userId, "v2"), TimeSpan.FromMinutes(10));
After: Immediate.Jobs
[QueueDefinition(Name = "critical", Priority = 100)]
public sealed class CriticalQueue;

[Handler, Job(Name = "send-welcome-email", MaxAttempts = 6)]
[UsesQueue<CriticalQueue>]
public sealed partial class SendWelcomeEmail(IEmailSender sender)
{
	public sealed record Payload(Guid UserId, string Template);

	private ValueTask HandleAsync(Payload payload, CancellationToken cancellationToken) =>
		new(sender.SendAsync(payload.UserId, payload.Template, cancellationToken));
}

// Inject SendWelcomeEmail.Scheduler where you called BackgroundJob.
await scheduler.EnqueueAsync(new(userId, "v2"), cancellationToken);
await scheduler.ScheduleAsync(new(userId, "v2"), TimeSpan.FromMinutes(10), cancellationToken);
  • The method arguments become the Payload record. Payloads must be JSON-serializable by the source generator, so pass IDs instead of entities or services.
  • Hangfire’s Attempts counts retries, while MaxAttempts counts every attempt. Attempts = 5 becomes MaxAttempts = 6. Hangfire retries 10 times by default. Immediate.Jobs defaults to 3 attempts, so set the value explicitly when the old default mattered.
  • Replace IBackgroundJobClient injections with the job’s generated Scheduler. It is scoped; resolve it from a scope in singleton services.
  • EnqueueAsync returns a JobHandle instead of a string job ID. Use handle.Value wherever you stored Hangfire’s ID.
  • Hangfire’s IJobCancellationToken becomes the CancellationToken parameter. It is cancelled on timeout and during shutdown.

Recurring jobs

Before: Hangfire
RecurringJob.AddOrUpdate<CleanupService>(
	"cleanup-sessions",
	x => x.DeleteExpiredSessions(),
	"*/5 * * * *",
	new RecurringJobOptions { TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Vienna") });
After: Immediate.Jobs
[Handler, Job(Name = "cleanup-sessions", Cron = "*/5 * * * *", TimeZone = "Europe/Vienna")]
public sealed partial class CleanupSessionsJob(AppDbContext db)
{
	private ValueTask HandleAsync(EmptyJobRequest request, CancellationToken cancellationToken) =>
		new(db.DeleteExpiredSessions(cancellationToken));
}
  • Five-field cron expressions copy over unchanged. Cron.Daily() and similar helpers become their expression strings ("0 0 * * *") or the @daily style macros.
  • Use IANA time-zone IDs, such as Europe/Vienna, not Windows IDs.
  • Hangfire 1.8’s MisfireHandling maps to MisfireHandlingMode: Relaxed becomes EnqueueOne (the default), Strict becomes EnqueueAll and Ignorable becomes EnqueueNone.
  • Recurring jobs have no payload. A Hangfire recurring job with arguments, such as one per tenant, becomes one recurring job that enqueues a payload job for each tenant, or a dynamic schedule through IRecurringJobScheduler when schedules are created at runtime.
  • RecurringJob.TriggerJob(id) becomes the scheduler’s TriggerNowAsync or JobMonitor.TriggerRecurringAsync(name). RecurringJob.RemoveIfExists becomes RemoveRecurringAsync for dynamic schedules. Code-defined schedules are removed when the job loses its Cron.

Preventing overlap

[DisableConcurrentExecution] takes a distributed lock around every execution. Choose the Immediate.Jobs setting that matches why you needed it:

  • For recurring jobs, use OverlapPolicy.Skip to drop a run while the previous one is unfinished, or OverlapPolicy.Queue to run them one after another. Both apply across all servers. Queue needs a provider with graph support.
  • For other jobs, MaxConcurrency = 1 limits executions per server, and a queue with Concurrency = 1 does the same for every job in that queue. When work must never overlap across servers, keep a lock or a conditional update inside the handler.

Continuations and batches

Before: Hangfire
var importId = BackgroundJob.Enqueue<Importer>(x => x.Import(fileId));
BackgroundJob.ContinueJobWith<Indexer>(importId, x => x.Rebuild(fileId));
After: Immediate.Jobs
JobHandle imported = await import.EnqueueAsync(new(fileId), cancellationToken);
await index.ScheduleAfterAsync(new(fileId), imported, cancellationToken: cancellationToken);

ScheduleAfterAsync also accepts ContinuationTrigger.Failure or Complete, which cover Hangfire’s JobContinuationOptions.OnAnyFinishedState. Hangfire.Pro batches map to IBatchScheduler, which saves a whole graph of jobs and dependencies in one operation and is included without a separate license. Continuations and batches need a provider with graph support: EF Core, LinqToDB or in-memory.

Filters, dashboard and monitoring

  • Job filters (IServerFilter, IElectStateFilter) become Immediate.Handlers behaviors for code that runs around each execution. Retry and state rules move into the [Job] settings.
  • Values you passed through job parameters, such as a tenant or culture, become context extractors.
  • The Hangfire Dashboard becomes the Immediate.Jobs dashboard. It is restricted to development until you set an authorization policy, where Hangfire used IDashboardAuthorizationFilter.
  • JobStorage.Current.GetMonitoringApi() becomes JobMonitor.

Drain Hangfire

  1. Deploy the ported jobs and stop calling BackgroundJob and RecurringJob.
  2. Keep AddHangfireServer running until the Hangfire dashboard shows no enqueued, scheduled or retrying jobs.
  3. Remove the Hangfire packages, server, dashboard and storage tables.

Migrate with an agent

Migrate from Hangfire with an AI agent

Copy this prompt into Claude Code, Codex, Copilot or another coding agent from the root of your repository.