Migrating from Hangfire

Port a Hangfire app: the delegation recipe, a concept-by-concept mapping, and a side-by-side cutover playbook.


Port a Hangfire app: the delegation recipe, a concept-by-concept mapping, and a side-by-side cutover playbook.

You will not find BackgroundJob.Enqueue anywhere in BackWave. There is no static facade, no expression-tree capture of a method call, no implicit job identity derived from a type and a method name. Instead you inject a client, enqueue a typed payload, and a separately-declared Handler runs it. That single shift, from capturing a call to routing a message, drives almost every difference in this guide. The rest is mechanical once you have internalized it.

This page is in three parts. First the delegation recipe: the smallest BackWave equivalent of a fire-and-forget Hangfire job. Then a concept-by-concept mapping table for everything else you used in Hangfire. Finally a phased cutover playbook for running both systems side by side and switching over without downtime or double execution.

Three shifts to front-load#

Before any code, hold these three differences in your head. They explain why the port is a re-shaping and not a line-for-line translation.

  • No static enqueue API. Hangfire's signature move is BackgroundJob.Enqueue(() => svc.Method(arg)), a static call that captures a lambda. BackWave has you inject a BackWaveClient from dependency injection and call an instance method. You enqueue a typed payload object, not a captured method call.
  • Jobs are messages plus Handlers, not captured lambdas. Hangfire serializes a method call: the type, the method, and the arguments. BackWave serializes a payload record keyed by an explicit Wire Name string and routes it to an IJobHandler<TJob>. The Wire Name is the job's identity in storage, stable across refactors, never tied to a CLR name.
  • Storage lives in your own database. Hangfire owns its own schema and defaults to SQL Server. BackWave runs over Postgres, SQL Server, or SQLite, and the job tables can live in your application's own database, so a Job can even commit inside your own transaction.

The design rationale behind these choices lives in How It Works. Here we stay practical.

Part 1: the delegation recipe#

Start from a typical Hangfire fire-and-forget. You have a service with a method, and you hand a lambda calling it to the static enqueuer.

Hangfire (before)
public class EmailService
{
    public void SendReceipt(Guid orderId) { /* ... */ }
}
 
// fire-and-forget
BackgroundJob.Enqueue<EmailService>(svc => svc.SendReceipt(orderId));
 
// delayed
BackgroundJob.Schedule<EmailService>(svc => svc.SendReceipt(orderId), TimeSpan.FromMinutes(30));

The smallest BackWave translation reuses the method body almost verbatim. Method form, where you attribute a Handler method and let the generator write the payload record and Handler for you, is the closest ergonomic match to Hangfire's "just call my method."

EmailJobs.cs
public sealed class EmailJobs(IEmailSender sender)
{
    [Job("send-receipt")]
    public async Task SendReceipt(Guid orderId, JobContext context, CancellationToken cancellationToken)
    {
        await sender.SendReceiptAsync(orderId, cancellationToken);
    }
}

A few things to note against Hangfire's conventions. The method must be public and return Task; Hangfire allowed void, BackWave does not, and a wrong shape is a build error. The [Job] attribute's Wire Name, "send-receipt", replaces Hangfire's implicit EmailService.SendReceipt identity, so you can rename the class freely later. The declaring class EmailJobs is registered scoped automatically, so constructor-injected dependencies like IEmailSender resolve per Attempt, exactly as a Hangfire job activated from DI would. The generator builds the payload record from the data parameters only; it recognizes JobContext and CancellationToken by type and excludes them, so SendReceipt(Guid orderId, ...) is enqueued as new SendReceipt(orderId).

Replace the static facade with an injected client#

At the call site, you inject BackWaveClient instead of reaching for a static.

Program.cs
app.MapPost("/orders/{id}/receipt", async (Guid id, BackWaveClient client) =>
{
    // Hangfire: BackgroundJob.Enqueue<EmailService>(s => s.SendReceipt(id));
    var jobId = await client.EnqueueAsync(new SendReceipt(id), dueTime: DateTimeOffset.UtcNow);
 
    // Hangfire: BackgroundJob.Schedule<EmailService>(s => s.SendReceipt(id), TimeSpan.FromMinutes(30));
    await client.EnqueueAsync(new SendReceipt(id), dueTime: DateTimeOffset.UtcNow.AddMinutes(30));
 
    return Results.Ok(new { jobId });
});

Three translations are happening here. BackgroundJob.Enqueue becomes client.EnqueueAsync(payload, DateTimeOffset.UtcNow). BackgroundJob.Schedule(..., delay) becomes the same call with a future dueTime; there is no delay parameter, you compute the instant yourself, and enqueuing now versus scheduling later is one code path that differs only by the time you pass. And where Hangfire returns a string job id, BackWave returns a Guid, minted client-side before any Worker runs, which you can use later as a dependency parent id.

Wire it up once at startup#

services.AddHangfire(...) plus app.UseHangfireServer() collapse into one AddBackWave call.

Program.cs
builder.Services.AddBackWave(backwave => backwave
    .UseStore(new PostgresJobStore(new PostgresStoreOptions
    {
        ConnectionString = builder.Configuration.GetConnectionString("App")!,
        AutoMigrate = true,
    }))
    .UseJobs(BackWaveJobs.Module)
    .AddWorkerGroup(new WorkerGroupOptions
    {
        Name = "default",
        Policy = new DispatchPolicy.Strict(["default"]),
    }));

UseStore names the Storage Adapter; the Postgres, SQL Server, and SQLite stores all take an options object, not a bare connection string. AutoMigrate = true is a dev convenience that creates the tables on startup; in production apply the schema through your migration pipeline instead and leave it false. UseJobs(BackWaveJobs.Module) registers the generated Job Registry and Handlers, replacing Hangfire's reflection-based activation. AddWorkerGroup is the analogue of AddHangfireServer: it stands up the Worker Group that claims and runs work. At least one group is required or nothing ever leaves the Queue, and the hosted pumps register automatically, so there is no separate worker host to start.

When the payload deserves a name#

If the work is data-shaped, class form maps a Hangfire job class onto a payload record plus a Handler. This is the same surface as method form, just spelled out.

OrderCharged.cs
[Job("order-charged", Queue = "payments")]
public sealed record OrderCharged(Guid OrderId);
 
public sealed class OrderChargedHandler(IPaymentGateway gateway) : IJobHandler<OrderCharged>
{
    public Task HandleAsync(OrderCharged job, JobContext context, CancellationToken cancellationToken)
        => gateway.ChargeAsync(job.OrderId, cancellationToken);
}

One gotcha that bites Hangfire porters specifically: Hangfire serialized arbitrary method arguments as JSON, so passing a List<T> or a DTO just worked. BackWave's generator understands a deliberate set of payload member types, the scalars and their Nullable<T> forms, and a string? or a collection or a nested object is a build error. When you hit that, store a reference such as an id and have the Handler fetch the data, or hand-register the Job with your own JSON type info. The full define-and-enqueue path, including this list and the source generator's diagnostics, lives in Define & Enqueue a Job.

Part 2: concept-by-concept mapping#

Once the delegation recipe clicks, the rest of your Hangfire surface translates row by row. Every BackWave cell below has a guide that goes deeper.

HangfireBackWave equivalent
BackgroundJob.Enqueue(() => svc.M(x))client.EnqueueAsync(new MPayload(x), DateTimeOffset.UtcNow)
BackgroundJob.Schedule(() => ..., delay)client.EnqueueAsync(payload, DateTimeOffset.UtcNow + delay)
Job method captured via lambda[Job("wire-name")] method (method form) or payload record plus IJobHandler<T> (class form)
Implicit Type.Method identityExplicit, mandatory Wire Name string, stable across refactors
IBackgroundJobClientBackWaveClient, injected from DI
Returned string job idReturned Guid, minted client-side
BackgroundJob.ContinueJobWith(parentId, () => ...)client.EnqueueDependencyAsync(payload, parentId, mode: ...)
Continuation on parent success (default)DependencyMode.OnSuccess (the default)
Continuation regardless of outcomeDependencyMode.OnAnyTerminal
IBatchClient / Batch.ContinueBatchWith (paid)Workflow (Pro): client.BuildWorkflow(...).AddJob(..., dependsOn: [...]).Build() plus EnqueueWorkflowAsync
RecurringJob.AddOrUpdate("id", () => ..., cron)client.UpsertRecurringAsync("id", Cron.Daily(...), template)
RecurringJob.RemoveIfExists("id")client.RemoveRecurringAsync("id")
RecurringJob.TriggerJob("id")op.TriggerScheduleNowAsync("id", actor)
Cron.Daily() helpers / cron stringsCron.Daily(hour, minute) and friends, or CronExpression.Parse(...)
TimeZoneInfo on a recurring jobtimeZone: IANA id argument, null for UTC
MisfireHandlingModeCatchUpPolicy.Skip (default) or CatchUpPolicy.Coalesce
[DisableConcurrentExecution] on recurringnoOverlap: true per schedule
[Queue("x")] / EnqueuedState[Job(Queue = "x")] type default plus queue: enqueue override
Queue priority via server queue orderWorker Group DispatchPolicy.Strict or Weighted
BackgroundJobServer and server optionsWorker Group: AddWorkerGroup plus WorkerGroupOptions
[AutomaticRetry(Attempts = n)]WorkerGroupOptions.RetryPolicy { MaxAttempts = n, Backoff = ... }
Exhausted retries to "Failed"Dead-Lettered terminal state
Undeserializable / no handler to "Failed"Quarantined terminal state (routing or decode failure)
[Tag] / job parameters for searchTags: type-default Labels, enqueue-time tags:, runtime context.AddTag
IJobFilter / IElectStateFilterTransition Observer (egress-only, cannot veto)
SQL Server / Redis storageStorage Adapter over your own database
Hangfire outbox or opt-in TransactionScope enlistmentNative transactional enqueue: client.EnqueueAsync(job, dbContext, dueTime) on your own transaction, no TransactionScope

A handful of caveats do not fit in a table cell and matter enough to spell out.

Priority is not a job property. Hangfire users sometimes simulate priority with per-job queues and server queue ordering. In BackWave priority lives entirely in a Worker Group's DispatchPolicy: a Strict ordered list that always drains higher queues first, or a Weighted smooth round-robin. A Job never carries a priority of its own.

Retries are per Worker Group, not per job. Hangfire's [AutomaticRetry(Attempts = ...)] decorates the job class. BackWave's MaxAttempts and Backoff sit on WorkerGroupOptions.RetryPolicy. To give one class of work a different retry shape, put it on its own Queue served by a group with its own policy. Retries & Error Handling covers the policy in full.

A lapsed Lease counts as an Attempt. A stalled or crashed Worker burns one unit of retry budget exactly like a thrown exception, then the Job becomes claimable again. Hangfire has the analogous invisibility timeout, but the numbers differ: BackWave's Lease defaults to 60 seconds. Size it above your worst-case Handler runtime and make long Handlers heartbeat.

Two terminal failure states, not one. Hangfire lumps everything into "Failed." BackWave separates Dead-Lettered, a Job that ran and kept failing, from Quarantined, a Job that could not be routed or decoded at all. Renaming a job class is safe because the Wire Name is decoupled from the CLR name. Changing a payload shape incompatibly is not: the stored bytes may stop decoding and the Job Quarantines. Evolve Job Payloads covers how to avoid that.

Delivery is At-Least-Once, the same class as Hangfire. A correctly-written Hangfire handler's idempotency assumptions carry over unchanged. If your Hangfire handlers were already safe to repeat, they stay safe here.

Workflows are a Pro feature. BuildWorkflow and EnqueueWorkflowAsync light up only when the BackWave.Pro package is referenced. Hangfire's Batches are also a paid feature, so the comparison is fair. The base dependency surface, EnqueueDependencyAsync, needs no Pro package and covers most continuation chains.

Part 3: the cutover playbook#

There is no migration tool to run and no codemod. What makes a cutover safe is that BackWave and Hangfire share nothing: separate schemas, separate clients, separate workers, optionally separate databases. They can run in the same process, so you can port one job at a time and switch producers incrementally. The recommended phasing:

Phase 0, install alongside. Add the BackWave packages and call AddBackWave(...) next to your existing AddHangfire(...). Both run in the same host. Use a separate AddWorkerGroup so BackWave's Workers and Hangfire's BackgroundJobServer coexist. Point BackWave at the same database if you like; the adapter creates its own tables, with AutoMigrate for dev or your migration pipeline for production.

Phase 1, dual-declare one job. For each Hangfire job you port, write the BackWave [Job] and Handler from Part 1, and leave the Hangfire job intact. The Handler can call the same underlying service method, so the business logic is shared, not duplicated.

Phase 2, switch the producer, drain the old. At the call site, flip BackgroundJob.Enqueue(...) to client.EnqueueAsync(...). New work now flows through BackWave while Hangfire's queue drains whatever was already enqueued. Because only one producer enqueues each item, there is no double-execution risk for new work. The producer switch is the cutover point for that job.

Phase 3, migrate recurring schedules. Schedules cannot drain, they re-fire, so swap them cleanly: in the same deploy, call RecurringJob.RemoveIfExists("id") on the Hangfire side and client.UpsertRecurringAsync("id", ...) on the BackWave side. Mind the timing. BackWave mints only future ticks and never back-fills, and CatchUpPolicy.Skip is the default, so a gap between removing the old schedule and the first new tick simply means missed runs, not a catch-up storm. If you need a guaranteed make-up run, pass catchUp: CatchUpPolicy.Coalesce. To force a first run at switch time, call op.TriggerScheduleNowAsync("id", actor). Recurring Schedules covers the options in detail.

Phase 4, port continuations and batches last. Move ContinueJobWith chains to EnqueueDependencyAsync and Batches to Workflows once their producers already run on BackWave. A dependency's parent id must be a BackWave Job id; you cannot make a BackWave Job depend on a Hangfire job, so the parents have to be ported first. Chain Jobs with Dependencies walks the dependency surface.

Phase 5, decommission. Once every producer is switched and Hangfire's queues are empty, watched through both dashboards, remove AddHangfire and AddHangfireServer and drop its schema.

During the overlap window, lean on idempotency. If the same logical work is ever live in both systems at once (a recurring schedule briefly defined on both sides, say), it runs twice. Both systems are At-Least-Once, so your Handlers should already tolerate that. The two dashboards mount at different paths, app.UseHangfireDashboard("/hangfire") and app.UseBackWaveDashboard("/backwave", options), so operators can watch both through the transition. One posture difference: both dashboards are interactive, but BackWave's operator actions are default-deny, individually audited, and delegated to your host's authorization, where Hangfire gates the whole dashboard with a single all-or-nothing authorization filter.

Gotchas to plan for#

A short list of edges Hangfire users hit first.

  • Payload size is capped and rejected, not truncated. A serialized payload over the store's limit throws ArgumentException at enqueue. Hangfire users who passed large arguments hit this first. Carry a reference, not the bytes.
  • string? payload members are rejected by the generator, along with collections and nested objects. Use a non-nullable string with a sentinel, or hand-register the Job.
  • Dead-Lettered and Quarantined are different fixes. Dead-Lettered means your code failed, so fix the code and requeue. Quarantined means a deploy problem, an unknown Wire Name or an undecodable payload, so deploy the fix before you requeue or it re-quarantines.
  • A job can declare at most 16 parents. Relevant if you are fanning a large Hangfire batch into a single dependency join; split wide fan-ins across stages.
  • Cancellation is cooperative. Like Hangfire's cancellation token, BackWave's CancellationToken fires through the heartbeat and never kills a thread. Honor it to stop promptly. See Cancel a Running Job.

Where to go next#