# Hangfire DisableConcurrentExecution: Wait, Skip, or Serialize Jobs

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-31
Canonical: https://cronradar.com/blog/hangfire-disable-concurrent-execution

> What Hangfire's DisableConcurrentExecution locks, why a waiting run fails and retries, and how to skip runs, serialize jobs, or lock across systems instead.

**`[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](https://github.com/HangfireIO/Hangfire/releases/tag/v1.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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/RecurringJobScheduler.cs#L325-L361) 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](https://cronradar.com/blog/hangfire-automatic-retry) has the timing for common schedules.
- **`Strict` misfire handling.** After downtime, the scheduler [creates one job per missed occurrence](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/RecurringJobEntity.cs#L150-L176) 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/BackgroundJobServerOptions.cs#L42), 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.TriggerJob` [create a background job immediately](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/RecurringJobManager.cs#L174-L191), whatever is already running.
- **At-least-once processing.** Hangfire's [worker](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/Worker.cs) 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/DisableConcurrentExecutionAttribute.cs#L46-L64) through the worker's storage connection. In `OnPerformed` it releases the lock, whether the method returned or threw:

```csharp
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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/DisableConcurrentExecutionAttribute.cs#L66-L81): `ErpSyncJobs.SyncOrders`. Three things follow:

- Every call shares one lock, whatever its arguments, so `ImportTenant(1)` waits for `ImportTenant(2)`.
- Overloads share it, and so do same-named classes in different namespaces: `Billing.SyncJob.Run` and `Crm.SyncJob.Run` both lock `SyncJob.Run`.
- A job registered through an interface, as in `AddOrUpdate<IErpSyncJobs>(...)`, locks the interface's name.

Since [1.8.0](https://github.com/HangfireIO/Hangfire/releases/tag/v1.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:

```csharp
// "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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.SqlServer/SqlServerDistributedLock.cs#L179-L220).

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`](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Storage/DistributedLockTimeoutException.cs), 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/BackgroundJobPerformer.cs#L194-L206) the same way as one from your method, so the attempt [moves to Failed](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/Worker.cs#L336-L342) 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/Worker.cs#L120), 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.SqlServer/SqlServerConnection.cs#L64-L68), so the attribute above holds `HangFire:ErpSyncJobs.SyncOrders`. SQL Server releases session-owned application locks [when the session ends](https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql), so a killed process frees the lock once its connection is gone. The flip side is noted in [Hangfire's source](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.SqlServer/SqlServerConnection.cs#L839-L842): 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](https://hangfire.io/blog/2016/08/10/hangfire.pro.redis-2.0.html) and the [one-minute expiration set in 2.1.0](https://www.hangfire.io/blog/2017/04/17/hangfire.pro.redis-2.1.0.html) 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`](https://github.com/hangfire-postgres/Hangfire.PostgreSql/blob/1.21.1/src/Hangfire.PostgreSql/PostgreSqlDistributedLock.cs#L156-L176), [10 minutes by default](https://github.com/hangfire-postgres/Hangfire.PostgreSql/blob/1.21.1/src/Hangfire.PostgreSql/PostgreSqlStorageOptions.cs#L42). 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`](https://github.com/hangfire-postgres/Hangfire.PostgreSql/blob/1.21.1/src/Hangfire.PostgreSql/PostgreSqlDistributedLockException.cs#L28).

Hangfire's [concurrency documentation](https://docs.hangfire.io/en/latest/background-processing/throttling.html) 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:

```csharp
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 one `sp_getapplock` call with a zero lock timeout, which [returns instead of waiting](https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql), and Hangfire.PostgreSql makes one insert attempt. Both throw `DistributedLockTimeoutException` when 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 `await` is fine.
- **A hung lock holder makes every later run skip.** Give the work a deadline, as in [Hangfire job stuck in Processing](https://cronradar.com/blog/hangfire-job-stuck-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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Server/Worker.cs#L324-L326), 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:

```csharp
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;
    });
}
```

```csharp
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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.NetCore/HangfireServiceCollectionExtensions.cs#L186-L212), and `[Queue]` [sets the queue every time the job is enqueued](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/QueueAttribute.cs), retries included. Queue names allow only lowercase letters, digits, underscores, and dashes ([configuring queues](https://docs.hangfire.io/en/latest/background-processing/configuring-queues.html)).

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](https://cronradar.com/blog/hangfire-recurring-job-not-running).

With a Hangfire Ace license, [Hangfire.Throttling](https://docs.hangfire.io/en/latest/background-processing/throttling.html) 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](https://www.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

```sql
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](https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql) 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](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.SqlServer/SqlServerDistributedLock.cs#L194-L201)), 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:

```sql
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;
\endif
```

`pg_try_advisory_xact_lock` [returns false without waiting](https://www.postgresql.org/docs/current/functions-admin.html) when another session holds the key, `\gset` stores the result in a psql variable, and `\if` reads `t` as true ([psql](https://www.postgresql.org/docs/current/app-psql.html)). 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](https://www.postgresql.org/docs/current/explicit-locking.html). PgBouncer's transaction pooling [never supports](https://www.pgbouncer.org/features.html) 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](https://docs.hangfire.io/en/latest/best-practices.html) ask for reentrant methods for this reason, and [preventing errors in scheduled automation](https://cronradar.com/blog/prevent-errors-in-scheduled-automation) has a checklist.

## Monitor Overlapping Hangfire Jobs With CronRadar

CronRadar's [Hangfire integration](https://docs.cronradar.com/integrations/hangfire) 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:

```csharp
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 `critical` as 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 a `start` with no `complete` or `fail`. Every report moves that time to the next occurrence, so while later runs keep skipping and completing, the monitor stays `healthy`. A deadline inside the job turns the hang into a `fail`, 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](https://cronradar.com/hangfire) 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.
