Hangfire 1.8 powers every background job in fullstackhero. The Jobs building block wraps it with four kit-specific filters: FshJobFilter preserves tenant + user context across enqueue → execute, FshJobActivator resolves jobs from a scoped DI container per execution, HangfireTelemetryFilter opens an OpenTelemetry activity per job, and LogJobFilter logs every state transition.
How it’s wired
builder.AddHeroPlatform(o => o.EnableJobs = true); // turns on AddHeroJobs
app.UseHeroJobDashboard(builder.Configuration); // mounts /hangfireStorage provider falls out of DatabaseOptions:Provider:
PostgreSQL→Hangfire.PostgreSqlMSSQL→ SQL Server storage
The kit defaults to 5 workers, 30-second heartbeat, queues default + email, 30-second polling.
Enqueueing a job
Inject IJobService and call EnqueueAsync / ScheduleAsync:
public sealed class SendInvoiceEmailCommandHandler(IJobService jobs) : ICommandHandler<SendInvoiceEmailCommand, Unit>{ public ValueTask<Unit> Handle(SendInvoiceEmailCommand cmd, CancellationToken ct) { jobs.Enqueue<SendInvoiceEmailJob>(j => j.RunAsync(cmd.InvoiceId, CancellationToken.None)); return ValueTask.FromResult(Unit.Value); }}Hangfire serializes the call signature; the activator resolves SendInvoiceEmailJob from a fresh DI scope when the job dequeues. The job can inject DbContext, IMailService, ICurrentUser, etc.
Recurring jobs in the kit
| Job | Module | Cron |
|---|---|---|
MonthlyInvoiceJob | Billing | 5 0 1 * * — 00:05 UTC on the 1st of each month |
PurgeOrphanedFilesJob | Files | hourly |
PurgeDeletedFilesJob | Files | 30 3 * * * — 03:30 UTC daily |
AuditRetentionJob | Auditing | 30 3 * * * — daily (opt-in via Auditing:Retention:Enabled) |
identity-outbox-dispatcher | Identity | every minute |
Register your own with IRecurringJobManager:
recurringJobs.AddOrUpdate<MyHourlyJob>( "my-hourly-job", job => job.RunAsync(CancellationToken.None), "0 * * * *", // top of every hour TimeZoneInfo.Utc);Retry policies
Per-job retry via the [AutomaticRetry] attribute:
[AutomaticRetry(Attempts = 4, OnAttemptsExceeded = AttemptsExceededAction.Fail, DelaysInSeconds = new[] { 30, 120, 600, 3600 })]public sealed class WebhookDispatchJob{ public async Task RunAsync(/* ... */) { /* ... */ }}The Webhooks module uses this exact pattern — 5 attempts total (1 initial + 4 retries) with 30 s / 2 min / 10 min / 1 h exponential backoff.
Dashboard
UseHeroJobDashboard(app, config) mounts the Hangfire dashboard at HangfireOptions:Route (default /hangfire), gated by HangfireCustomBasicAuthenticationFilter.
{ "HangfireOptions": { "UserName": "admin", "Password": "set-via-secrets", "Route": "/hangfire" }}Always HTTPS in production. Consider IP whitelisting in the reverse proxy on top of basic auth.
Gotchas
- Hangfire serializes arguments. Pass primitives (Guid, string, int) — not large object graphs. If you need state, look it up inside the job from the DI-resolved DbContext.
FshJobActivatorcreates a new scope per job. Don’t try to share state via scoped services across job invocations; each gets a clean container. Use Valkey / DB for cross-job state.- Stale-lock cleanup runs ~10s after host startup.
HangfireStaleLockCleanupServicereleases locks from servers that crashed without releasing. The delay avoids interfering with legitimate startup-time job acquisition. - Background jobs and tenant context. Enqueuing happens inside a request scope (so
ICurrentUserworks). Execution happens on a background thread (so it doesn’t).FshJobFilterbridges this — but if you write your own filter or skip the kit’s activator, restore the tenant context manually.
Related
- Jobs building block — the implementation reference.
- Billing module —
MonthlyInvoiceJobrecurring schedule. - Files module — orphan + retention purge jobs.
- Webhooks module —
[AutomaticRetry]with exponential backoff.