Persistent Jobs with Hangfire: Fire-and-Forget, Delayed, Recurring and Continuations
Hangfire writes a method call to the database as a type name, a method name, parameter types and arguments serialized to JSON; it survives restarts and is retried 10 times by default. That's why only ids should be passed as arguments: a DbContext can't be serialized, and an entity is stored as a stale copy. Renaming a method sends old jobs still in the queue into retries with a method-not-found error.
Version note: The code in this post was built and run with .NET 10, Hangfire 1.8.25 (Hangfire.AspNetCore and Hangfire.SqlServer), Microsoft.Data.SqlClient 7.1.1 and SQL (Structured Query Language) Server 2025.
Guess First
When an order is confirmed in Bookstore, there are three jobs: the confirmation email right away, the invoice email if the confirmation email succeeds, and a review request three days later. All three were written to the queue with Hangfire and are waiting in the database while the app is down. Meanwhile a developer renames the SendInvoiceAsync method to SendInvoiceEmailAsync and deploys the new version. When the new version starts, what happens to the waiting invoice job? Does it bind to the new name automatically, get skipped silently, or something else?
Setup: SQL Server Storage and the Hangfire Server
Hangfire has two parts: a client that writes jobs to storage, and a server that reads them from storage and runs them. They can live in the same process or in separate ones. The setup in ASP.NET Core:
builder.Services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")));
builder.Services.AddHangfireServer(options =>
{
options.WorkerCount = 5;
options.Queues = ["critical", "default"]; // order is priority
});
builder.Services.AddScoped<IEmailSender, LogEmailSender>();
builder.Services.AddScoped<OrderEmailJobs>();
builder.Services.AddScoped<InventoryReportJob>();
UseSqlServerStorage creates the HangFire schema in SQL Server by itself on first start. Hangfire.SqlServer doesn't bring a SQL client package; you add Microsoft.Data.SqlClient to the project yourself, and if both client packages are present, the Hangfire documentation says Microsoft.Data.SqlClient is preferred.
One build detail you shouldn't miss: for netstandard2.0, Hangfire.Core depends on Newtonsoft.Json with a lower bound of 11.0.1, and NuGet picks that version. The first build gave these warnings:
warning NU1903: Package 'Newtonsoft.Json' 11.0.1 has a known high severity vulnerability, https://github.com/advisories/GHSA-5crp-9r3c-p9vr
warning NU1903: Package 'System.Security.Cryptography.Pkcs' 9.0.18 has a known high severity vulnerability, https://github.com/advisories/GHSA-846f-f764-76mx
The second warning isn't from Hangfire but from System.Security.Cryptography.Pkcs 9.0.18, which comes with Microsoft.Data.SqlClient 7.1.1. Explicitly raising both to newer versions in the project cleared the warnings:
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.25" />
<PackageReference Include="Hangfire.SqlServer" Version="1.8.25" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
The log of the first start shows the defaults:
05:03:23.466 info: Hangfire.SqlServer.SqlServerObjectsInstaller[0] Start installing Hangfire SQL objects...
05:03:23.972 info: Hangfire.SqlServer.SqlServerObjectsInstaller[0] Hangfire SQL objects installed.
05:03:24.073 info: Hangfire.BackgroundJobServer[0] Starting Hangfire Server using job storage: 'SQL Server: localhost,17103@Bookstore05'
05:03:24.073 info: Hangfire.BackgroundJobServer[0] Using the following options for SQL Server job storage: Queue poll interval: 00:00:00.
05:03:24.073 info: Hangfire.BackgroundJobServer[0] Using the following options for Hangfire Server: Worker count: 5 Listening queues: 'critical', 'default' Shutdown timeout: 00:00:15 Schedule polling interval: 00:00:15
05:03:24.092 info: Hangfire.Server.BackgroundServerProcess[0] Server mac:7753:ab84a3e2 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, SqlServerHeartbeatProcess, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
Before touching my own settings, I also printed the defaults: WorkerCount=20 Queues=[default]. In Hangfire's source code, the default worker count is Math.Min(Environment.ProcessorCount * 5, 20); on my 10-core machine that's 20. Each worker is a thread that runs one job; if most jobs wait on I/O (input/output) this is enough, if they are CPU (Central Processing Unit) heavy you should lower it. The queue list is ordered: workers look at critical first and move to default only when it's empty. That's a priority mechanism but not a fair one; if critical never empties, default is never processed.
Four Job Types: Enqueue, Schedule, ContinueJobWith and Recurring
app.MapPost("/orders/{id:int}/confirm", (int id, IBackgroundJobClient jobs) =>
{
// fire-and-forget: straight into the queue
string emailJobId = jobs.Enqueue<OrderEmailJobs>(j => j.SendConfirmationAsync(id, CancellationToken.None));
// continuation: the invoice if the email succeeds
string invoiceJobId = jobs.ContinueJobWith<OrderEmailJobs>(emailJobId,
j => j.SendInvoiceAsync(id, CancellationToken.None));
// delayed: review request three days later (5 seconds in the demo)
string reviewJobId = jobs.Schedule<OrderEmailJobs>(j => j.SendReviewRequestAsync(id, CancellationToken.None),
TimeSpan.FromSeconds(5));
return Results.Accepted(value: new { emailJobId, invoiceJobId, reviewJobId });
});
public sealed class OrderEmailJobs(IEmailSender email)
{
public Task SendConfirmationAsync(int orderId, CancellationToken ct) =>
email.SendAsync($"customer{orderId}@example.com", $"Order #{orderId} confirmed", ct);
public Task SendInvoiceAsync(int orderId, CancellationToken ct) =>
email.SendAsync($"customer{orderId}@example.com", $"Invoice for order #{orderId}", ct);
public Task SendReviewRequestAsync(int orderId, CancellationToken ct) =>
email.SendAsync($"customer{orderId}@example.com", $"How was order #{orderId}?", ct);
}
IRecurringJobManager recurring = app.Services.GetRequiredService<IRecurringJobManager>();
recurring.AddOrUpdate<InventoryReportJob>(
"nightly-inventory-report",
job => job.RunAsync(CancellationToken.None),
"0 3 * * *",
new RecurringJobOptions { TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul") });
IBackgroundJobClient is a service that comes from DI (Dependency Injection); using it instead of the static BackgroundJob class improves testability. The CancellationToken.None in the expression is just a placeholder: when Hangfire runs the job, it puts a real token there that is cancelled when the server shuts down. Hangfire also creates OrderEmailJobs from ASP.NET Core's DI container, opening a new scope for each job; that's why taking scoped services in the job class's constructor is safe.
I sent one request to the endpoint:
$ curl -s -X POST http://127.0.0.1:17105/orders/42/confirm
{"emailJobId":"1","invoiceJobId":"2","reviewJobId":"3"}
05:03:31.352 info: Bookstore.Api.LogEmailSender[0] Email to customer42@example.com: Order #42 confirmed
05:03:31.381 info: Bookstore.Api.LogEmailSender[0] Email to customer42@example.com: Invoice for order #42
05:03:39.151 info: Bookstore.Api.LogEmailSender[0] Email to customer42@example.com: How was order #42?
The confirmation email and the invoice email, its continuation, went out within the same second. I had scheduled the review request 5 seconds later, but it ran at 39.151, not at 31.352. The reason is that the DelayedJobScheduler, which moves scheduled jobs into the queue, polls every 15 seconds by default (SchedulePollingInterval). For "three days later" the difference doesn't matter; if you need second precision, Hangfire is the wrong tool.
How Is a Job Stored?
The shortest way to understand Hangfire is to look at its tables. The HangFire schema has 11 tables: AggregatedCounter, Counter, Hash, Job, JobParameter, JobQueue, List, Schema, Server, Set, State. The three jobs from a moment ago:
SELECT Id, StateName, InvocationData, Arguments FROM HangFire.Job ORDER BY Id;
1|Succeeded|{"t":"Bookstore.Api.OrderEmailJobs, p05","m":"SendConfirmationAsync","p":["System.Int32","System.Threading.CancellationToken, mscorlib"]}|["42",null]
2|Succeeded|{"t":"Bookstore.Api.OrderEmailJobs, p05","m":"SendInvoiceAsync","p":["System.Int32","System.Threading.CancellationToken, mscorlib"]}|["42",null]
3|Succeeded|{"t":"Bookstore.Api.OrderEmailJobs, p05","m":"SendReviewRequestAsync","p":["System.Int32","System.Threading.CancellationToken, mscorlib"]}|["42",null]
A job is three things: which method (m) of which type (t), with which parameter types (p), and with which arguments. The arguments sit in a separate column as JSON (JavaScript Object Notation): "42" is the order id, and null is the placeholder for CancellationToken. Thanks to UseSimpleAssemblyNameTypeSerializer, the type name is written without the assembly version (p05); that matters so that old jobs' type names can still be resolved in every new version of the app.
A job's life is in the State table. The invoice job (2) and the review request (3):
SELECT JobId, Name, Reason, CreatedAt FROM HangFire.State WHERE JobId IN (2,3) ORDER BY Id;
2|Awaiting|NULL|2026-11-08 02:03:31.343
3|Scheduled|NULL|2026-11-08 02:03:31.353
2|Enqueued|NULL|2026-11-08 02:03:31.363
2|Processing|NULL|2026-11-08 02:03:31.377
2|Succeeded|NULL|2026-11-08 02:03:31.383
3|Enqueued|Triggered by DelayedJobScheduler|2026-11-08 02:03:39.133
3|Processing|NULL|2026-11-08 02:03:39.150
3|Succeeded|NULL|2026-11-08 02:03:39.153
The continuation waits in Awaiting until its parent finishes; the delayed job waits in Scheduled and moves to Enqueued with "Triggered by DelayedJobScheduler". A recurring job, however, isn't a job but a definition; it lives in the Hash table and produces a new job each time it fires:
SELECT [Key], Field, Value FROM HangFire.Hash WHERE [Key] LIKE 'recurring-job:%' ORDER BY Field;
recurring-job:nightly-inventory-report|CreatedAt|1794103403995
recurring-job:nightly-inventory-report|Cron|0 3 * * *
recurring-job:nightly-inventory-report|Job|{"t":"Bookstore.Api.InventoryReportJob, p05","m":"RunAsync","p":["System.Threading.CancellationToken, mscorlib"],"a":[null]}
recurring-job:nightly-inventory-report|NextExecution|1794182400000
recurring-job:nightly-inventory-report|Queue|default
recurring-job:nightly-inventory-report|TimeZoneId|Europe/Istanbul
recurring-job:nightly-inventory-report|V|2
NextExecution is Unix time in milliseconds: 1794182400000 is 9 November 2026 00:00 UTC (Coordinated Universal Time), i.e. 03:00 in Istanbul. The time zone we discussed in the previous post is stored here durably.
Arguments: Pass Only the Id
Since arguments are serialized, passing the entity directly may look convenient. I gave an order as the argument:
public sealed record OrderLine(string Isbn, string Title, int Quantity, decimal UnitPrice);
public sealed record Order(int Id, string CustomerEmail, string ShippingAddress, List<OrderLine> Lines);
app.MapPost("/demo/entity", (IBackgroundJobClient jobs) =>
{
var order = new Order(44, "grace@example.com", "221B Baker Street, London",
[new("978-0441013593", "Dune", 1, 12.99m), new("978-0547928227", "The Hobbit", 2, 9.50m)]);
return jobs.Enqueue<OrderEmailJobs>(j => j.SendConfirmationForAsync(order, CancellationToken.None));
});
SELECT Id, Arguments FROM HangFire.Job WHERE Id = 7;
7|["{\"Id\":44,\"CustomerEmail\":\"grace@example.com\",\"ShippingAddress\":\"221B Baker Street, London\",\"Lines\":[{\"Isbn\":\"978-0441013593\",\"Title\":\"Dune\",\"Quantity\":1,\"UnitPrice\":12.99},{\"Isbn\":\"978-0547928227\",\"Title\":\"The Hobbit\",\"Quantity\":2,\"UnitPrice\":9.50}]}",null]
The whole order, with its address and lines, is in the job table. That has three problems. First, staleness: if the customer changes their address before the job runs, the job uses the old address. Second, personal data: this table shows up on the dashboard, and jobs are kept for a while by default even after they succeed. Third, size: big objects bloat the storage and every retry.
Worse is passing an object tied to a scope. When I tried giving a DbContext as an argument, the job couldn't even be created:
using var db = new BookstoreDbContext(new DbContextOptionsBuilder<BookstoreDbContext>().UseSqlite("Data Source=:memory:").Options);
try
{
string id = BackgroundJob.Enqueue<ReportJobs>(j => j.RunAsync(db));
Console.WriteLine($"enqueued {id}");
}
catch (Exception ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
if (ex.InnerException is not null) Console.WriteLine($" inner {ex.InnerException.GetType().Name}: {ex.InnerException.Message}");
}
BackgroundJobClientException: Background job creation failed. See inner exception for details.
inner JsonSerializationException: Self referencing loop detected for property 'Context' with type 'BookstoreDbContext'. Path 'ChangeTracker'.
The rule is simple: give the job an id, and let the job load fresh data in its own scope. Hangfire's own documentation also recommends keeping arguments small and simple.
Changing a Job: Renames and async void
Back to the opening question. I ran the app as a client only (no server) and wrote three jobs for order 43, then renamed SendInvoiceAsync to SendInvoiceEmailAsync and started the server:
# no server, old code
$ curl -s -X POST http://127.0.0.1:17105/orders/43/confirm
{"emailJobId":"4","invoiceJobId":"5","reviewJobId":"6"}
# SendInvoiceAsync -> SendInvoiceEmailAsync, new version starts with the server
05:04:17.550 info: Bookstore.Api.LogEmailSender[0] Email to customer43@example.com: Order #43 confirmed
05:04:17.594 warn: Hangfire.AutomaticRetryAttribute[0] Failed to process the job '5': an exception occurred. Retry attempt 1 of 10 will be performed in 00:00:26. System.InvalidOperationException: The type `Bookstore.Api.OrderEmailJobs` does not contain a method with signature `SendInvoiceAsync(Int32, CancellationToken)` [...]
05:04:32.481 info: Bookstore.Api.LogEmailSender[0] Email to customer43@example.com: How was order #43?
The confirmation email went out, because that method didn't change. The invoice job, the continuation, couldn't find the SendInvoiceAsync in its InvocationData and failed with an InvalidOperationException. More importantly, this error turned into a retry: "Retry attempt 1 of 10 will be performed in 00:00:26". Hangfire's default AutomaticRetry filter retries every failed job 10 times at growing intervals; since the method isn't coming back, those 10 attempts are wasted and the job eventually lands in Failed.
During a rolling deploy the same thing happens the other way round: an old, not-yet-updated server picks up a job written by the new version and can't find the new method. The safe way is to version methods: add the new method, keep the old one for a while as a thin bridge that calls the new one, and delete it once no old jobs remain in the queue. Adding a parameter falls under the same rule, because InvocationData stores the parameter types too.
Then there's the method's return type. I tried to queue an async void method:
public async void FireAndForgetVoid(int orderId) => await Task.Delay(10);
app.MapPost("/demo/async-void", (IBackgroundJobClient jobs) =>
{
try { jobs.Enqueue<OrderEmailJobs>(j => j.FireAndForgetVoid(1)); return Results.Ok(); }
catch (Exception ex) { return Results.Text($"{ex.GetType().Name}: {ex.Message}"); }
});
// NotSupportedException: Async void methods are not supported. Use async Task instead.
Hangfire rejects it when the job is created; good, because nobody can know when an async void method finishes or whether it failed. Job methods should return Task.
Licensing: Core, Pro and Ace
Hangfire's pricing page (at the time of writing) lists four editions. The Open edition is free; Hangfire Core is licensed under LGPL (GNU Lesser General Public License) 3.0 and, according to the page, can be used in commercial and proprietary applications. The supported storages are SQL Server and In-Memory; PostgreSQL support comes from the community-written Hangfire.PostgreSql package (currently 1.21.1).
The paid editions are per organization per year: Startup is 500 dollars and includes Hangfire Pro; Pro brings batches (job groups created atomically, plus batch continuations), Redis storage and Windows performance counters. Business is 1,500 dollars and includes Hangfire Ace on top of Pro; Ace's only package is Hangfire.Throttling, which offers concurrency limiters such as mutexes, semaphores and rate limiters. Enterprise starts at 4,500 dollars. Prices can change; check the page before you buy.
In Production
Split workers and queues by the kind of work. If you put email jobs that take seconds and report jobs that take minutes on the same queue, a few reports grab all the workers and keep the emails waiting. Move long work to a separate queue and, if needed, to a separate server (a separate process with its own WorkerCount).
Think of the storage as part of the database. If Hangfire's tables are in the app's database, jobs and order data share the same backup and the same failover; but heavy job traffic also shares resources with the app's queries. As the load grows, consider a separate database.
Set up failure notifications. The default retry policy hides the first error behind 10 attempts; the job reaches Failed only hours later. We'll separate permanent errors from retries and alert on failed job counts in the seventh and tenth posts.
Common Pitfalls
An async void job. Hangfire throws NotSupportedException when creating the job; job methods should return Task.
Passing a DbContext or an entity as an argument. A DbContext can't be serialized; an entity is stored as a stale copy containing personal data. Pass only the id.
Changing a recurring job's id without deleting the old one. AddOrUpdate works by id; if you change nightly-inventory-report to nightly-stock-report, the old one isn't deleted and both definitions run every night. When renaming, don't forget to call RecurringJob.RemoveIfExists("nightly-inventory-report").
Changing a method signature while jobs are in the queue. Old jobs can't find the method and cycle through retries for 10 attempts. Add a new method and keep the old one as a bridge.
Expecting second precision from delayed jobs. Scheduled jobs are polled every 15 seconds by default.
A Small Experiment
★☆☆ Lower SchedulePollingInterval to 1 second and measure the review request job's delay again. What does this setting cost the database?
★★☆ Version the invoice job's method: add SendInvoiceEmailAsync, keep SendInvoiceAsync as a bridge that calls the new method, and rerun the rename scenario. How do you verify that no old jobs remain in the queue?
★★★ Run two separate Hangfire servers: one listening only to the critical queue with 2 workers, the other to default with 10 workers. Queue 50 emails and 5 long report jobs; how does the email delay change compared to a single-server setup?
In the next post we move to the tool that's stronger on the scheduling side: the job/trigger split in Quartz.NET 4, the ActiveX Data Objects .NET (ADO.NET) JobStore on PostgreSQL, a two-node cluster sharing one database, misfire behaviors and RFC (Request for Comments) 5545 recurrence rules.