Skip to main content
David Dew Mallick

David Dew Mallick

ProjectsExperienceSkillsBlog
Back to blog
queuesretriesworkershorizon

Laravel Queue Retry and Backoff Explained

How Laravel queue retries, backoff, and timeouts actually work, why jobs silently double-run, and how to configure them for idempotent workers.

Oct 6, 2026 · 8 min read

A job that throws gets retried. That is the whole promise, and it is also where production breaks: the retry runs while the first attempt is still executing, the timeout kills a worker mid-write, and the failed jobs table fills with duplicates nobody reads. This guide covers how Laravel queue retry and backoff actually behave, and how to configure them so a retry is safe.

How does Laravel decide when to retry a job?

Laravel retries a job when the handler throws an uncaught exception or when the worker kills it for exceeding its timeout. The attempt counter lives in the job payload, not in your code, so every retry re-runs the same serialized job with an incremented attempts value. Once attempts exceed the configured maximum, the job moves to the failed jobs table.

The counter is incremented by the worker before the handler runs, which matters more than it sounds. If a worker is killed by the OOM killer or a container restart, the increment has already been persisted, so the job is not lost, it is retried. That is the intended safety net, and it is also why a job that crashes the process repeatedly will exhaust its attempts without ever logging a normal exception.

You can inspect the raw counter inside the job:

public function handle(): void
{
    if ($this->attempts() > 1) {
        Log::warning('Retrying job', ['id' => $this->job->getJobId()]);
    }

    // ...
}

Three places set the retry limit, and they resolve in a specific order. The job class wins over the worker flag, which wins over the config default. Getting this order wrong is the most common reason a job retries far more times than you expected.

WhereSyntaxApplies to
Job classpublic $tries = 3;That job only, highest priority
Job methodpublic function tries(): intThat job, evaluated at runtime
Worker flagphp artisan queue:work --tries=3Every job the worker processes
Configconfig/queue.php connection retry_afterVisibility timeout, not attempt limit

Note the last row carefully. retry_after is not a retry count. It is the number of seconds a reserved job stays invisible to other workers before the queue releases it again. Setting it too low is the single most destructive misconfiguration in this list, and the next section explains why.

Why does retry_after cause duplicate job execution?

A job is re-delivered when its reservation expires, which happens when retry_after elapses before the job finishes. If the job is still running, a second worker picks it up and runs it concurrently. The fix is to make retry_after strictly greater than the job timeout, with a margin for the worker to record completion.

Walk through the failure. A job has a 60 second timeout and the connection has retry_after = 90. The worker reserves the job at t=0. At t=60 the worker's own timeout fires, the child process is killed, and the job is released back for retry. Fine. Now set retry_after = 30 with the same 60 second timeout. At t=30 the reservation expires while the first worker is still working. A second worker reserves and starts the same job. At t=60 both workers are killed or one completes and the other completes again. Your job ran twice, and nothing in the logs says so unless the job is idempotent enough to notice.

The rule

Keep retry_after at least 10 to 20 seconds above the longest realistic job runtime, and set the job timeout below retry_after. If your jobs can legitimately run for minutes, raise both together rather than tuning one.

This interacts directly with how you handle failures. If a job is retried by reservation expiry, it does not go through your failed() method, because from the queue's perspective nothing failed. That is why a duplicate side effect can appear without any entry in the failed jobs table. The Laravel queue best practices post covers the broader worker configuration; this section is the specific ordering constraint.

How do you configure backoff so retries do not hammer a failing dependency?

Backoff is the delay before the next attempt. Without it, a job hitting a down API retries immediately, three times in a row, and adds load to a service that is already struggling. Laravel accepts either a single integer applied to every retry or an array indexed by attempt number.

class SyncOrderToErp implements ShouldQueue
{
    public $tries = 5;

    // Attempt 1 waits 10s, attempt 2 waits 30s,
    // attempt 3 waits 120s, attempts 4+ wait 300s.
    public $backoff = [10, 30, 120, 300];

    public function handle(): void
    {
        // ...
    }
}

If the attempt number exceeds the array length, the last value repeats. A single integer such as public $backoff = 60; is simpler and fine for jobs whose failure is transient and cheap to retry. Use the array form when the dependency has a recovery time you can reason about, such as a rate-limited third party or a database that needs a few seconds to fail over.

Backoff delays are implemented as a release with a delay, so the job returns to the queue rather than blocking a worker. That means a long backoff does not hold a worker process, which is the behavior you want. It also means the delay is not precise: a job released for 300 seconds becomes available after 300 seconds, but only runs when a worker polls it.

Timeouts are separate from backoff

A timeout kills the worker's child process; backoff delays the next attempt. They are configured independently and both need attention. The timeout can be set per job with public $timeout = 90; or globally with --timeout=90 on the worker command. The job-level value overrides the flag, so a single laravel queue timeout setting on the worker will not save a job that declares its own.

One caveat that catches people: the timeout uses pcntl_alarm under the hood, so it only works when the pcntl extension is available and the worker is not running in a context that blocks signals. If pcntl is missing, the timeout is silently ignored and the job runs until it finishes or the process dies. Verify it rather than assuming:

php -m | grep pcntl
php artisan queue:work --timeout=5 --tries=1

Then dispatch a job that sleeps for 30 seconds and confirm it is killed near the 5 second mark. If it runs the full 30, your timeout is not in effect and retry_after is the only thing standing between you and duplicate execution.

What belongs in the failed jobs table?

Laravel writes a job to the failed_jobs table when attempts are exhausted or when the job calls $this->fail() explicitly. The row stores the connection, queue, payload, and the exception, so you can inspect and retry it later with queue:retry. The table is not a log; it is a work queue for failures that need a decision.

Two operational habits make it useful instead of noise. First, prune it on a schedule so it does not grow unbounded:

// routes/console.php
Schedule::command('queue:prune-failed --hours=168')->weekly();

Second, alert on growth rather than on individual rows. A steady trickle of failures is normal in a system that talks to external services. A sudden spike means a dependency broke, and that is the signal worth paging on. Horizon exposes failed job counts per queue, which makes laravel horizon queue monitoring the easier place to alert from than querying the table directly. The Laravel testing post covers how to assert failure behavior in tests so you catch a broken failed() method before it reaches production.

If your job has a failed() method, remember it runs in a separate worker process from handle(). Any state you set on the job instance during handle() is not available in failed() unless it was serialized. Read what you need from the exception or re-query the database.

Making retries safe: idempotency in practice

A retry is only safe if running the job twice produces the same end state as running it once. Since reservation expiry, timeouts, and manual retries can all cause a second execution, idempotency is not optional for jobs with side effects such as charging a card or sending an email.

The cheapest pattern is a guard keyed on a stable identifier. Before doing the work, attempt to claim the operation; if the claim already exists, exit without side effects.

public function handle(): void
{
    $claimed = DB::table('processed_orders')
        ->insertOrIgnore(['order_id' => $this->orderId]);

    if ($claimed === 0) {
        return; // already handled by an earlier attempt
    }

    // do the side effect exactly once
}

The unique index on order_id is what makes this correct. insertOrIgnore relies on the database rejecting the duplicate, so the guard is atomic even with two workers racing. Do not implement this with a read-then-write check in PHP; the gap between the read and the write is exactly where the duplicate slips through.

For jobs that call an external API, prefer the provider's idempotency key support when it exists. Where it does not, the database guard above is the fallback. Either way, the decision is per job, not global, and it should be made when the job is written rather than after the first duplicate incident.

Frequently asked questions

Does Laravel retry a job that times out?

Yes. A timeout kills the worker's child process, and the job is released back to the queue with its attempt count already incremented. It will be retried until attempts are exhausted, then written to the failed jobs table. If pcntl is unavailable the timeout never fires, so the job runs to completion instead.

What is the difference between tries and retry_after?

tries is the maximum number of attempts before a job is marked failed. retry_after is the reservation timeout: how long a job stays invisible to other workers before the queue releases it. Setting retry_after below the job timeout causes concurrent duplicate execution, not extra retries.

Can I retry a job from the failed jobs table?

Yes. php artisan queue:retry all re-dispatches every failed job, and queue:retry {id} targets one. The job is re-queued with a fresh attempt count, so a job that failed because of a persistent bug will fail again. Fix the cause before retrying in bulk.

DD

David Dew Mallick

Software Engineer

I build AI-driven SaaS infrastructure and backend systems with Laravel, AWS, and SQL, and write about the engineering decisions behind them.

GitHubLinkedInEmail

More posts

  • Oct 7, 2026 · 8 min read

    PHP Type Juggling: Pitfalls and Fixes

    How PHP's loose comparison rules cause real bugs, which behaviors changed in PHP 8, and when to use strict types and strict comparisons to avoid them.

  • Oct 7, 2026 · 7 min read

    Eloquent upsert and updateOrCreate at Scale

    Compare Eloquent upsert, updateOrCreate, and insertOrIgnore on MySQL and PostgreSQL, including race conditions, deadlocks, and safe bulk imports.

On this page

  • How does Laravel decide when to retry a job?
  • Why does retry_after cause duplicate job execution?
  • How do you configure backoff so retries do not hammer a failing dependency?
  • Timeouts are separate from backoff
  • What belongs in the failed jobs table?
  • Making retries safe: idempotency in practice
  • Frequently asked questions
  • Does Laravel retry a job that times out?
  • What is the difference between tries and retry_after?
  • Can I retry a job from the failed jobs table?
Back to top

End of record

Back to top↑

David Dew Mallick

Dhaka, Bangladesh

Contact

  • david.dew.mallick@g.bracu.ac.bd
  • GitHub
  • LinkedIn

2026 David Dew Mallick

  • Privacy
  • Terms
  • Cookies