A search-index provider has a bad ten minutes. Three thousand product-sync jobs fail, each one with $backoff = 600. Ten minutes later the provider is healthy again, and all three thousand jobs wake up in the same second and hit it at once. It falls over a second time, this time because of you.

Fixed retry delays do this. Every job that failed together retries together, and the recovery attempt is heavier than the original traffic ever was.

How Laravel decides the delay

Worth understanding before trying to change it, because it is not evaluated where most people assume.

When you dispatch a job, Laravel reads your $backoff property or backoff() method, flattens the value to a comma-separated string, and stores it in the payload. Later, when the worker needs to release a failed job, it splits that string and picks the entry matching the attempt number:

// roughly what the worker does
$backoff[$job->attempts() - 1] ?? last($backoff)

Two consequences follow from that, and both matter.

First, an array gives you an escalating ladder, and once you run past the end of it, the last value repeats for every further attempt:

public function backoff(): array
{
    return [30, 120, 600];
}

Attempt one waits 30 seconds, then 120, then 600, then 600 again for as long as it keeps going.

Second, and this is the part that catches people: the value is computed once, at dispatch time. It is frozen into the payload. The method does not run again on each failure.

Jitter at dispatch

That freezing is not a problem for jitter. It is the whole trick. Because backoff() runs once per job, a random component gives every job its own private schedule, decided the moment it was queued:

public function backoff(): array
{
    return [
        30 + random_int(0, 30),
        120 + random_int(0, 120),
        600 + random_int(0, 300),
    ];
}

Three thousand jobs dispatched over the morning now hold three thousand different ladders. When the outage clears, they come back spread across a five-minute window instead of arriving in one spike.

Keep the random range proportional to the delay. A ten-minute backoff with two seconds of jitter is still a spike.

Jitter per attempt

If you want a fresh random delay on every failure rather than a fixed ladder, do it where the decision is actually made each time, inside the job:

public function handle(SearchIndex $index): void
{
    try {
        $index->push($this->product);
    } catch (ConnectionException) {
        $this->release(random_int(60, 300));
    }
}

release() takes the delay at the moment of release, so this is genuinely re-rolled on each attempt. Use it when the right delay depends on something you only know at run time, such as a Retry-After header.

Remember that a release still consumes an attempt. On a long outage, pair this with retryUntil() so the job expires on a clock rather than an attempt count.

It pairs with a circuit breaker

Jitter spreads the herd out. It does not stop the herd forming. If a dependency is properly down, the better first move is to stop calling it at all with a circuit breaker, then let jitter smooth the return once the circuit closes.

The two solve different halves of the same incident: the breaker protects the service while it is broken, jitter protects it in the minute after it recovers.

A note on scheduled work

The same clustering shows up without any failure at all. If a nightly command dispatches ten thousand jobs at midnight, they are all available at midnight. Spreading dispatch with a small random delay costs nothing and keeps the first minute of the day calm:

$products->each(fn ($product) => SyncProductToIndex::dispatch($product)
    ->delay(now()->addSeconds(random_int(0, 900))));

Wrapping up

Identical backoff values turn one outage into two. Add a random component to backoff() so each job carries its own retry schedule from the moment it is dispatched, use release() with a random delay when the timing has to be decided per attempt, and remember the ladder's last value repeats once the attempts run past the end of the array.

Related