Laravel Job Retries and Backoff with ShouldBeUnique and WithoutOverlapping
· 9 min read · Boring Observability
Verified against Laravel 13.24
When a queued job throws and has attempts left, the worker puts the same payload back on the queue with a delay
taken from backoff. Nothing is dispatched again, so none of the checks that run at dispatch run again.
ShouldBeUnique, ShouldBeUniqueUntilProcessing and WithoutOverlapping each do
something different with their lock during that delay, and most of the surprises with these three happen there.
This post follows a job through the gap between two attempts for each mechanism, then covers two ways the retry
schedule itself goes wrong: overlap releases that move a job further along its backoff array, and a
backoff that runs past retryUntil(). Every behaviour here was checked with probe jobs on Laravel 13.24.
Key takeaways#
ShouldBeUniqueholds its lock through every backoff. A dispatch during the delay is dropped. The lock is never renewed, souniqueForhas to cover the whole retry schedule or a duplicate gets in partway through.ShouldBeUniqueUntilProcessingholds no lock during backoff. The lock went when the first attempt started, so a fresh copy can be queued next to the retry, and the older retry can run last.WithoutOverlappingreleases its lock before the backoff starts. Another copy can run while the failed one waits. You get one at a time, not in order.- Overlap releases count as attempts, and
backoffis indexed by attempts. Two collisions before the first real exception make it use the third entry of the array. - A backoff that lands after
retryUntil()replaces the real exception. The job fails at pickup withMaxAttemptsExceededException, which is what the failed job records.
What the worker does between two attempts#
A worker increments the attempt count when it picks a job up. If handle() throws, the worker first checks
whether this was the last attempt (attempts >= tries, or retryUntil() already passed). If
it wasn't, it releases the job with a delay:
// Illuminate\Queue\Worker::calculateBackoff()
$backoff = $job->backoff() ?? $options->backoff; // "10,60,300" or [10, 60, 300]
return $backoff[$job->attempts() - 1] ?? last($backoff);
The released job is the same payload with its attempt count raised. The constructor arguments are what they were at
dispatch, and so are the unique lock's owner token and the retryUntil() timestamp.
PendingDispatch doesn't run, so no unique lock is taken and none is renewed.
Backoff only applies to exceptions. A worker that is killed mid-job, by a timeout or the OOM killer, never reaches the
release, and the job comes back when the connection's retry_after expires. The
timeout and
retry_after post covers that path. Everything below is about the exception path.
| During the backoff | ShouldBeUnique |
ShouldBeUniqueUntilProcessing |
WithoutOverlapping |
|---|---|---|---|
| Lock held? | Yes, until uniqueFor runs out |
No, released when attempt 1 started | No, released in a finally as the attempt threw |
| A new dispatch | Dropped | Queued | Queued |
| Another copy can run | No | Yes | Yes, ahead of the retry |
ShouldBeUnique keeps the lock for the whole retry schedule#
The lock is released in two places: after an attempt that finishes without being released, and in the failure handler when the job fails for good. A release for a retry is neither, so the lock stays held while the job waits. We dispatched a copy during a 60-second backoff and it was dropped, and a copy dispatched after the retry succeeded was queued. That is usually the behaviour you want.
The cost is that uniqueFor counts down from the dispatch and nothing extends it. It has to cover every
attempt and every backoff, plus however long the job can sit in the queue:
class SyncInvoices implements ShouldQueue, ShouldBeUnique
{
public $tries = 4;
public $timeout = 120;
public function backoff(): array
{
return [60, 300, 900];
}
// 1260s of backoff + 4 × 120s of runtime + room for a queue backlog
public $uniqueFor = 3600;
}
With uniqueFor = 30 and a 60-second backoff, the lock expired while the first job was still waiting to
retry. A second dispatch was queued, and both copies ran.
Laravel 13.24 made the release owner-checked, and that limits the damage. When the original job finally succeeded, its release used the owner token from its own dispatch, which no longer matched, so it left the second copy's lock alone and a third dispatch was dropped. On earlier versions every release was a force release: the original freed the second copy's lock and let a third one in.
Both copies still run. A retry carries its original payload, so a check at pickup can tell the old copy from the
new one. Our experimental
unique-job-middleware
package does that: it stores the owner token in the payload and deletes a retry whose lock now belongs to a newer
copy. It doesn't help with the ShouldBeUniqueUntilProcessing case below, where the lock is free by
the time the retry runs.
ShouldBeUniqueUntilProcessing has nothing to hold during backoff#
ShouldBeUniqueUntilProcessing releases its lock just before handle() runs on the first
attempt. If that attempt throws, the job waits out its backoff with no lock at all, and a new dispatch goes straight
through. In our probe the queue then held two jobs: the retry, due in 60 seconds, and the fresh copy, due now.
"At most one waiting copy" therefore doesn't include copies waiting to retry. For a job that recomputes from current state that is harmless. It matters when the job carries its data in the constructor:
class PushPriceToChannel implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
public function __construct(
public int $productId,
public int $priceInCents,
) {}
}
Dispatch it with 1000, let the push time out once, and dispatch it again with 1200 during the backoff. The 1200 copy
is available immediately and runs first. The 1000 copy runs when its backoff ends and overwrites the newer price.
Nothing fails and nothing is logged. Pass the id alone and read the price in handle(), or use a
debounced job (below), which drops the stale retry.
When the retry starts, it tries to release the lock again. On 13.24 that release uses the owner token from the first dispatch, so it leaves the fresh copy's lock alone: a third dispatch was dropped until the fresh copy started. Older versions force-released here too, so a retry could let a third copy in.
An overlap release behaves differently from an exception. If WithoutOverlapping sends the job back before
handle() runs, Laravel keeps the unique lock, because the job never started. In our probe a dispatch
made while the first copy was blocked on the overlap lock was dropped. That combination is what the
ShouldBeUnique
post recommends for "latest state wins" jobs, and it stays correct under contention. It stops holding once
handle() throws.
WithoutOverlapping lets another copy in while the failed one waits#
The middleware is short enough to quote whole:
if ($lock->get()) {
try {
$next($job);
} finally {
$lock->release();
}
} elseif (! is_null($this->releaseAfter)) {
$job->release($this->releaseAfter);
}
The exception from handle() passes through the finally, so the overlap lock is gone before
the worker has even calculated the backoff. Any copy that becomes available during the delay takes the lock and runs.
In our probe copy A threw with a 10-second backoff, copy B was dispatched, and B ran before A's retry.
WithoutOverlapping promises that two copies won't run at the same time. It doesn't promise they run in
dispatch order, and a failing job is exactly the one that gets reordered. If order matters, as with applying
ledger entries for one account, a job has to check its preconditions in handle(), or the work for that key
has to go through something that is ordered, such as a chain or a queue with a single worker.
Overlap releases move a job along its backoff array#
A job blocked by WithoutOverlapping is released after the worker has already counted the attempt. The
ShouldBeUnique
post covers how that uses up tries. It also changes the backoff, because the backoff is picked by
attempt number:
backoff() = [10, 60, 300], releaseAfter(5)
Attempt 1: overlap lock held, released for 5s
Attempt 2: overlap lock held, released for 5s
Attempt 3: handle() throws, backoff[3 - 1] = 300s
The first real failure waits five minutes instead of ten seconds. Under heavy contention every exception lands on
last($backoff). So write the array assuming any entry can be the first one used, or return a single value
from backoff() for jobs behind release-based middleware. RateLimited releases the same way
and shifts the index the same way.
A backoff that runs past retryUntil() loses the exception#
retryUntil() is turned into a timestamp at dispatch. When handle() throws, the worker fails
the job at once only if that timestamp has already passed. Otherwise it releases the job with the full backoff, even
if the backoff ends after the deadline.
public $backoff = 120;
public function retryUntil(): DateTimeInterface
{
return now()->addSeconds(60);
}
Our probe job threw on its first attempt and was released for 120 seconds. When a worker picked it up, the deadline
had passed, and the worker failed it before handle() ran:
Illuminate\Queue\MaxAttemptsExceededException:
App\Jobs\DeadlineJob has been attempted too many times.
That is the exception on the failed job, and the one your failed() method receives. The exception that
actually broke the job was reported when it was thrown, so it is in your logs or error tracker, but nothing
connects it to the failed job. Keep the sum of the backoffs inside the retryUntil() window, with room
for queue wait, so the last attempt runs and fails with its own exception.
On a ShouldBeUniqueUntilProcessing job, a pickup failure on the first attempt also strands the unique
lock. That case is covered in
Gotcha
6 of the ShouldBeUnique post. On later attempts it can't happen, because the lock was released when the first
attempt started.
Debounced jobs drop the retry instead#
Laravel 13.6 added #[DebounceFor]. Every dispatch overwrites an owner token in the cache and is queued
with a delay, and when a worker picks up a job whose token is no longer current, it deletes the job and fires
JobDebounced. The check runs on every pickup, retries included:
#[DebounceFor(30)]
class PushPriceToChannel implements ShouldQueue
{
public function debounceId(): string
{
return (string) $this->productId;
}
}
In our probe the first copy threw and was released for 60 seconds, and a second dispatch was made during that
backoff. When the retry came up it was deleted without running, and only the newer copy ran. That is the behaviour
the price example above needs. A debounced job can't also implement ShouldBeUnique: Laravel throws a
LogicException at dispatch.
Sizing the numbers together#
Each of these settings is usually chosen on its own, and most of the problems above come from them disagreeing. Work out the retry window first and derive the rest from it:
class RecomputeAccountReport implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
public $timeout = 120;
public function backoff(): int
{
return 60; // flat: overlap releases can't shift it
}
public function retryUntil(): DateTimeInterface
{
return now()->addMinutes(30); // the retry window
}
public $uniqueFor = 2400; // window + time a backlog can hold it
public function middleware(): array
{
return [
(new WithoutOverlapping("account-report:{$this->accountId}"))
->releaseAfter(60)
->expireAfter(180), // > $timeout, < retry_after (300)
];
}
}
uniqueForis longer than the retry window plus a realistic queue wait, because the lock is never renewed.- The backoffs add up to less than the
retryUntil()window, so the last attempt fails with its own exception. backoffis flat, or written so that any entry can come first, if the job sits behindWithoutOverlappingorRateLimited.expireAfteris longer thantimeout, and shorter thanretry_after, so a killed worker's lock is gone before its job comes back.
Seeing the gap in a dashboard#
Horizon shows a job waiting out its backoff in the same delayed list as a job dispatched with
delay(), and a failed job carries only its last exception. Skyline, the Horizon replacement we build,
lists retries separately from scheduled jobs, can run a retrying job immediately once you have shipped the fix, and
records the exception from each earlier attempt in a
Previous Attempts panel, so the
exception behind a MaxAttemptsExceededException stays attached to the job. Its
Locks & Limits screen shows which unique locks are held
while a job waits.
The ShouldBeUnique post covers the rest of the lock behaviour, including chains, batches and locks without a TTL, and the queue job lifecycle follows a job from dispatch to deletion.
Frequently asked questions
Does ShouldBeUnique keep the lock while a job waits to retry?
Yes. The unique lock is released when an attempt finishes without being released, or when the job fails for good. A release for a retry is neither, so the lock stays held through every backoff and dispatches made in that time are dropped. The lock is not renewed, so uniqueFor has to cover every attempt and backoff plus queue wait, or it expires and a duplicate is queued.
Why was a second ShouldBeUniqueUntilProcessing job queued while the first was retrying?
Because its lock was released just before the first attempt ran. When that attempt throws, the job waits out its backoff with no lock, so a new dispatch goes through. The queue then holds the retry and the fresh copy, and the older retry can run last. Use #[DebounceFor] if the retry should be dropped in favour of the newer dispatch.
Does WithoutOverlapping hold its lock during backoff?
No. The middleware releases the lock in a finally block, so it is gone before the worker releases the job with its backoff. Another copy can take the lock and run while the failed one waits, which means WithoutOverlapping keeps copies from running at once but does not keep them in dispatch order.
Why is my job's backoff longer than I configured?
Laravel picks the backoff by attempt number, as backoff[attempts - 1]. Releases by WithoutOverlapping or RateLimited count as attempts without running the job, so after two collisions the first real exception uses the third entry of the array. Use a single backoff value for jobs behind release-based middleware, or write the array assuming any entry can come first.
Why does my job fail with MaxAttemptsExceededException instead of its real exception?
Often because its backoff ended after its retryUntil() deadline. The worker only fails a job on the spot if the deadline has already passed when it throws; otherwise it releases it with the full backoff. The next pickup is past the deadline, so the job is failed before handle() runs, with MaxAttemptsExceededException as its recorded exception. Keep the backoffs inside the retryUntil() window.
Keep reading
9 min read
Full-Page Caching in Laravel: Large Redis Entries, predis vs phpredis, and Compression
Full-page cache entries in Redis can reach megabytes. Compare predis and phpredis, enable phpredis zstd...
9 min read
Why Our Laravel Response Cache Kept Missing: Click IDs, Cache Keys and a Cached noindex
Why spatie/laravel-responsecache kept missing: click ids splitting the cache, mismatched keys, clear()...