Hangfire DisableConcurrentExecution: Wait, Skip, or Serialize Jobs
Updated
Contents
[DisableConcurrentExecution(timeoutInSeconds)] makes Hangfire take a distributed lock named after the job's class and method before the method runs, so a second run waits for the first instead of running beside it. The timeout is how long the second run waits, not how long the first may run. If the lock isn't free in time, the waiting run fails with a DistributedLockTimeoutException and goes through AutomaticRetry like any other failure. The attribute ignores job arguments unless you give it a resource format, holds a worker while it waits, and never skips a run. To skip, take the lock yourself with a zero timeout. To serialize a group of different jobs, give them a queue with one worker.
Everything below was checked against the source of Hangfire 1.8.25, the current release on October 24, 2026. Examples assume ASP.NET Core and Hangfire.SqlServer unless a section says otherwise.
Why a Hangfire Job Runs Twice at the Same Time#
Hangfire doesn't limit a job to one running copy. Each of these starts a second copy while the first is still going:
- The recurring scheduler doesn't wait. At each occurrence it creates and enqueues a new background job without looking at the previous one. A 7-minute job on
*/5 * * * *overlaps itself on every run. - Retries of an earlier occurrence. Each occurrence has its own retry chain, so a retry of the 01:00 run can start next to the 02:00 run. Hangfire automatic retry has the timing for common schedules.
Strictmisfire handling. After downtime, the scheduler creates one job per missed occurrence and enqueues them together, and free workers run them in parallel.- Workers and servers. A server runs
Math.Min(Environment.ProcessorCount * 5, 20)workers by default, and every server on the same storage fetches from the same queues. Any free worker takes the next copy. - Trigger now. The dashboard button and
RecurringJob.TriggerJobcreate a background job immediately, whatever is already running. - At-least-once processing. Hangfire's worker requeues a job whose processing didn't finish, so a job orphaned by a dead process runs again, and a job requeued from the dashboard runs beside the old copy if that copy ignores its cancellation token.
What DisableConcurrentExecution Does#
The attribute is a server filter. In OnPerforming, before your method runs, it acquires a distributed lock through the worker's storage connection. In OnPerformed it releases the lock, whether the method returned or threw:
using Hangfire;
public class ErpSyncJobs
{
// A second run waits up to 5 minutes for this one to release the lock.
[DisableConcurrentExecution(timeoutInSeconds: 300)]
public void SyncOrders()
{
Console.WriteLine("Syncing orders");
}
}The lock lives in storage, so it applies across every server that uses the same Hangfire storage.
The Lock Name Ignores Arguments#
Without a resource format, the lock name is the type name without its namespace, a dot, and the method name: ErpSyncJobs.SyncOrders. Three things follow:
- Every call shares one lock, whatever its arguments, so
ImportTenant(1)waits forImportTenant(2). - Overloads share it, and so do same-named classes in different namespaces:
Billing.SyncJob.RunandCrm.SyncJob.Runboth lockSyncJob.Run. - A job registered through an interface, as in
AddOrUpdate<IErpSyncJobs>(...), locks the interface's name.
Since 1.8.0, a second constructor takes a format string. Hangfire fills it with the job's arguments, using the invariant culture, and lowercases the result:
// "import-tenant:42" blocks only another run for tenant 42.
[DisableConcurrentExecution("import-tenant:{0}", 300)]
public void ImportTenant(long tenantId)
{
Console.WriteLine($"Importing tenant {tenantId}");
}Arguments are formatted with ToString(), so use IDs and strings, not objects. Two methods given the same fixed resource string exclude each other.
What timeoutInSeconds Means#
It's the longest the second run waits for the lock. The first run keeps the lock for as long as it runs, so the attribute is not a time limit on the job. On SQL Server, Hangfire calls sp_getapplock in slices of at most one second until the timeout passes.
A timeout of 0 is accepted and means a single attempt. It doesn't skip the run: if the lock is taken, the attempt fails at once and is retried.
When the Lock Isn't Free in Time#
The filter throws DistributedLockTimeoutException, a subclass of TimeoutException, with the message "Timeout expired. The timeout elapsed prior to obtaining a distributed lock on the '...' resource." Hangfire wraps a filter's exception the same way as one from your method, so the attempt moves to Failed and AutomaticRetry schedules a retry, up to 10 by default. Your method never ran, so there is nothing to undo, but each retry waits for the lock again.
To send lock timeouts straight to Failed instead, add ExceptOn = new[] { typeof(DistributedLockTimeoutException) } (Hangfire 1.8.15 and later) to the method's AutomaticRetry attribute, with using Hangfire.Storage;.
A Waiting Run Holds a Worker#
The worker moves the job to Processing before any filter runs, and the lock call blocks that worker's thread, so a waiting run shows as Processing and does nothing else. While a long run holds the lock, each new occurrence and retry takes a worker until its timeout, and other jobs queue behind them.
So make the timeout long enough to cover what a normal previous run still needs, and shorter than the schedule's interval, so waiting runs from consecutive occurrences don't stack up. If a run has nothing to do while the previous one is going, don't wait at all: skip it.
How the Lock Behaves on Each Storage#
Each storage implements the lock differently:
| Storage | Lock | Released when | Watch for |
|---|---|---|---|
| Hangfire.SqlServer 1.8 | sp_getapplock, exclusive, owned by the session of a dedicated connection |
The lock is disposed, or the session ends | A dropped connection releases the lock while the job keeps running |
| Hangfire.Pro.Redis | A lock with a sliding expiration, renewed while held | The lock is disposed, or the expiration passes after the holder stops renewing it | A dead holder's lock blocks others until it expires |
| Hangfire.PostgreSql 1.21 (community) | A row in its lock table, never refreshed |
The lock is disposed, or the next acquirer finds the row older than DistributedLockTimeout |
A run longer than that timeout can lose its lock to the next run |
On SQL Server, Hangfire prefixes the resource with the schema name, so the attribute above holds HangFire:ErpSyncJobs.SyncOrders. SQL Server releases session-owned application locks when the session ends, so a killed process frees the lock once its connection is gone. The flip side is noted in Hangfire's source: when the connection is closed or broken, the "application lock is already released by SQL Server itself", and the running job isn't told.
Hangfire.Pro.Redis is closed source; its release notes describe the sliding expiration added in 2.0 and the one-minute expiration set in 2.1.0 to reduce delays from abandoned locks.
On Hangfire.PostgreSql 1.21.1, every attempt to take a lock first deletes that resource's row if it is older than DistributedLockTimeout, 10 minutes by default. Set it in PostgreSqlStorageOptions above your longest run; the cost is that a lock abandoned by a crash blocks for that long. Its timeout exception, PostgreSqlDistributedLockException, derives from DistributedLockTimeoutException.
Hangfire's concurrency documentation is blunt about the limit: DisableConcurrentExecution "may help a bit by narrowing the safety violation surface, but it heavily relies on an active connection, which may be broken (and lock is released) without any notification for our background job."
Skip the Run When the Previous One Is Still Going#
Take the lock inside the job with a zero timeout, and return if another run holds it:
using System;
using System.Threading;
using System.Threading.Tasks;
using Hangfire;
using Hangfire.Storage;
using Microsoft.Extensions.Logging;
public class ErpSyncJob
{
private readonly JobStorage _storage;
private readonly ILogger<ErpSyncJob> _logger;
// AddHangfire() registers the configured JobStorage for dependency injection.
public ErpSyncJob(JobStorage storage, ILogger<ErpSyncJob> logger)
{
_storage = storage;
_logger = logger;
}
public async Task RunAsync(CancellationToken cancellationToken)
{
using var connection = _storage.GetConnection();
using var runLock = TryAcquire(connection, "erp-sync:run");
if (runLock is null)
{
_logger.LogInformation("ERP sync skipped: the previous run still holds the lock.");
return;
}
await SyncOrdersAsync(cancellationToken);
}
// Returns null instead of waiting when another run holds the lock.
private static IDisposable? TryAcquire(IStorageConnection connection, string resource)
{
try
{
return connection.AcquireDistributedLock(resource, TimeSpan.Zero);
}
catch (DistributedLockTimeoutException)
{
return null;
}
}
private static Task SyncOrdersAsync(CancellationToken cancellationToken) =>
Task.Delay(TimeSpan.FromMinutes(7), cancellationToken);
}
// Registration, for example in Program.cs:
// RecurringJob.AddOrUpdate<ErpSyncJob>("erp-sync", job => job.RunAsync(CancellationToken.None), "*/5 * * * *");How it behaves:
- One attempt, no waiting. With
TimeSpan.Zero, Hangfire.SqlServer makes onesp_getapplockcall with a zero lock timeout, which returns instead of waiting, and Hangfire.PostgreSql makes one insert attempt. Both throwDistributedLockTimeoutExceptionwhen the lock is held. Test a zero timeout on Hangfire.Pro.Redis before relying on it; its source isn't public. - Only the acquire call is guarded. A lock timeout from inside the work still fails the run.
- The skipped run succeeds. Hangfire records Succeeded, so the log line is the only sign of a skip.
- The lock holds its own connection for the whole run. Both storages track locks per connection object, not per thread, so releasing after an
awaitis fine. - A hung lock holder makes every later run skip. Give the work a deadline, as in Hangfire job stuck in Processing, so a hang becomes a failure and releases the lock.
A server filter could do this for many methods by setting context.Canceled = true in OnPerforming. Since 1.8.0, Hangfire moves a filter-canceled job to Deleted, not Succeeded, and how CronRadar.Hangfire 0.1.0 reports that isn't documented, so monitored jobs should use the in-job version.
Serialize a Whole Category of Work With a One-Worker Queue#
When different jobs must not overlap each other, such as every job that writes to the same ERP, put them in one queue and serve it with a single worker:
using Hangfire;
builder.Services.AddHangfire(config =>
config.UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")));
// The general-purpose server must not list the "erp" queue.
builder.Services.AddHangfireServer(options => options.Queues = new[] { "default" });
// Enable on exactly one instance: every instance that runs this server adds a worker.
if (builder.Configuration.GetValue<bool>("Hangfire:ProcessErpQueue"))
{
builder.Services.AddHangfireServer(options =>
{
options.Queues = new[] { "erp" };
options.WorkerCount = 1;
});
}using Hangfire;
[Queue("erp")]
public class ErpJobs
{
public void PushInvoices() => Console.WriteLine("Pushing invoices");
public void PullStockLevels() => Console.WriteLine("Pulling stock levels");
}Each AddHangfireServer call registers another hosted server, and [Queue] sets the queue every time the job is enqueued, retries included. Queue names allow only lowercase letters, digits, underscores, and dashes (configuring queues).
The trade-offs:
- One worker means one in the whole deployment. Enable the server on three instances and you have three workers. During a rolling deploy, the old and new instance can both serve the queue for a while.
- It queues; it doesn't skip. Occurrences that arrive while a job runs wait in Enqueued, then all run, one after another.
- One slow or hung job holds up the whole category.
- If that instance is down, the queue stops. Jobs wait in Enqueued, which looks like a recurring job that isn't running.
With a Hangfire Ace license, Hangfire.Throttling offers Mutex and Semaphore filters that limit jobs by resource name without dedicated workers: a throttled job is rescheduled, a minute later by default, instead of holding a worker. It's a paid package on a private NuGet feed; on hangfire.io/pricing (checked October 24, 2026), Ace comes with the Business ($1,500 per organization per year) and Enterprise subscriptions, not Startup. Its docs warn that a mutex doesn't prevent the same background job from running twice in some corner cases.
Lock Across Systems With sp_getapplock or pg_try_advisory_lock#
When the other party isn't a Hangfire job, such as a SQL Agent job, a script on another host, or a service in another language, use a lock in the database both sides use.
SQL Server#
DECLARE @result int;
EXEC @result = sp_getapplock
@Resource = N'erp-sync',
@LockMode = 'Exclusive',
@LockOwner = 'Session',
@LockTimeout = 0; -- return -1 instead of waiting
IF @result < 0
BEGIN
PRINT 'erp-sync is already running; skipping.';
RETURN;
END;
EXEC erp.SyncOrders;
EXEC sp_releaseapplock @Resource = N'erp-sync', @LockOwner = 'Session';Application locks belong to the current database and their names are compared case-sensitively, so both sides must use the same name in the same database. If the work fails before the release call, a session lock stays until the session ends. For work that fits in one transaction, @LockOwner = 'Transaction' releases it on commit or rollback.
To exclude a Hangfire job too, use Hangfire's name for the lock. A Hangfire.SqlServer job that locks erp-sync holds an exclusive session lock on HangFire:erp-sync (default schema) for the public principal in the Hangfire database (source), so a script requesting N'HangFire:erp-sync' there contends with it.
PostgreSQL#
Advisory locks use a 64-bit key, or two 32-bit keys, that you choose. A transaction-level lock is released at commit or rollback, so a psql script can't leave it behind:
BEGIN;
SELECT pg_try_advisory_xact_lock(724001) AS acquired \gset
\if :acquired
SELECT erp.sync_orders();
COMMIT;
\else
\echo 'erp-sync is already running; skipping.'
ROLLBACK;
\endifpg_try_advisory_xact_lock returns false without waiting when another session holds the key, \gset stores the result in a psql variable, and \if reads t as true (psql). For work that can't run in one transaction, use pg_try_advisory_lock and pg_advisory_unlock, which hold the lock until it's unlocked or the session ends. PgBouncer's transaction pooling never supports session-level advisory locks.
Hangfire.PostgreSql's own locks are table rows, not advisory locks, so a Hangfire job that has to exclude an outside system on PostgreSQL must take the same advisory lock on its own database connection.
Locks Don't Make a Run Safe to Repeat#
Every lock above reduces overlap; none stops the same work from running twice. A run that dies after sending emails but before Hangfire records Succeeded runs again from the first line. A lock released by a dropped connection or an expired row lets a second copy in. A retry repeats whatever the failed attempt already did.
Make a repeat harmless: upserts instead of blind inserts, a record of processed periods keyed on the occurrence time, and idempotency keys on external calls. Hangfire's best practices ask for reentrant methods for this reason, and preventing errors in scheduled automation has a checklist.
Monitor Overlapping Hangfire Jobs With CronRadar#
CronRadar's Hangfire integration discovers recurring jobs at startup, reconciles the registry every 15 minutes by default, and reports start and then complete or fail for each run through a global Hangfire filter, with no monitoring code in job methods:
using CronRadar.Hangfire;
// After AddHangfire(). Reads the application key from CRONRADAR_API_KEY.
builder.Services.AddCronRadarHangfire();How each pattern shows up:
- Waiting with
DisableConcurrentExecution. A run that waits and then gets the lock completes normally, just later than usual. A lock timeout is a failed attempt in Hangfire, and the published CronRadar.Hangfire 0.1.0 (NuGet's latest, checked October 24, 2026) can report a failure before Hangfire finishes retrying, including cancellations. A run that timed out waiting can therefore alert even if its retry succeeds. Reporting only after retries are exhausted requires the pending 0.2.1 release. - Skipping inside the job. A skipped run returns normally and reports as a completed run. That keeps skips quiet, and it can also hide a hung lock holder, because the stuck window is per job, not per run. The monitor goes
criticalas stuck when the job's next expected run plus the grace period (60 seconds for monitors CronRadar.Hangfire 0.1.0 discovers, reset on every reconciliation) passes while the last report is astartwith nocompleteorfail. Every report moves that time to the next occurrence, so while later runs keep skipping and completing, the monitor stayshealthy. A deadline inside the job turns the hang into afail, which alerts immediately. - The one-worker queue. If the instance that serves it is down, queued runs report nothing, and CronRadar raises a missed-run alert once the expected time plus grace passes.
Every recurring job is monitored unless its method has [SkipMonitor], and alerts go to email, Slack, a webhook, Discord, or PagerDuty. Hangfire setup and coverage has the overview.
Frequently Asked Questions#
Does DisableConcurrentExecution work across multiple servers?#
Yes, when every server uses the same Hangfire storage, because the lock lives there. Servers on different databases or schemas don't share locks. It narrows overlap rather than ruling it out: a dropped connection or an expired lock can release it while the job is still running.
What timeout should I use for DisableConcurrentExecution?#
Long enough to cover what a normal previous run still needs, and shorter than the schedule's interval, so waiting runs don't pile up while each holds a worker. A run that doesn't get the lock in time fails with DistributedLockTimeoutException and is retried. A timeout of 0 doesn't skip the run; it fails immediately.
How do I skip a Hangfire job if it is already running?#
Inside the job, open a connection from JobStorage, call AcquireDistributedLock(resource, TimeSpan.Zero), catch DistributedLockTimeoutException around that call only, and return. The skipped run ends as Succeeded. Give the work a deadline too, or a hung run makes every later run skip.
Why does my Hangfire job run twice at the same time?#
Hangfire doesn't limit a job to one copy. The recurring scheduler starts each occurrence without waiting for the previous one, retries of an older occurrence run beside newer ones, Strict misfire handling and Trigger now add copies, and any free worker on any server can take them. An orphaned job also runs again from the start.
Does DisableConcurrentExecution consider job arguments?#
Not by default: the lock name is the type name without its namespace plus the method name, so every call shares one lock. Since Hangfire 1.8.0 you can pass a format string, as in [DisableConcurrentExecution("import-tenant:{0}", 300)], which Hangfire fills with the job's arguments, giving one lock per argument value.
Put this guide to work
Connect your Hangfire job to monitoring
Follow the current integration guide, check the release limitations, and verify a real run and alert delivery.
NuGet currently installs CronRadar.Hangfire 0.1.0. Setup and discovery work, but this release can report failures before Hangfire finishes retrying, including cancellations. Reporting only after retries are exhausted requires the pending 0.2.1 release. Every reconciliation sets each discovered monitor's grace period to CronRadar.CronRadar.DefaultGracePeriodSeconds, 60 unless set in code, replacing dashboard edits.