Laravel Queue Retry Strategies With Redis

September 18, 2026 · 3 views
Laravel Queue Retry Strategies With Redis

A job that fails silently at 3 AM costs more than the bug that caused it — it costs the hours you spend the next morning reconstructing what actually happened. If you run background processing in Laravel, getting your Laravel queue retry strategies right is the difference between a self-healing system and a pile of dead jobs nobody noticed.

Laravel's queue system gives you retries out of the box, but the default behavior — retry immediately, a fixed number of times, with no backoff — is wrong for almost every real-world failure mode. A third-party API rate limit doesn't care that you retried again 0.2 seconds later. A database deadlock under load gets worse, not better, when ten failed jobs all retry at once. This post covers how to configure retries, backoff, and failure handling so your queues actually recover instead of just hammering a broken dependency.

Why the default retry behavior fails you

By default, a failed job in Laravel is retried according to the tries property or the --tries flag on your worker, with no delay between attempts unless you configure one. That works fine for a job that fails due to a random, transient blip. It works badly for:

  • Rate-limited external APIs, where immediate retries just extend the rate-limit window
  • Database contention, where retrying instantly recreates the same lock conflict
  • Downstream outages, where hammering a dead service delays recovery and wastes worker capacity
  • Jobs with side effects, where a naive retry can double-charge a customer or send a duplicate email if idempotency isn't handled

The fix isn't "retry more" — it's retry smarter, with exponential backoff, sensible limits, and a clear plan for what happens when a job truly can't succeed.

Configuring exponential backoff

Laravel lets you define a backoff method (or property) on the job class that returns either a fixed number of seconds or an array of increasing delays for successive attempts:

class ProcessPaymentWebhook implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public $tries = 5;

    public function backoff(): array
    {
        // 10s, 30s, 60s, 120s, 300s between attempts
        return [10, 30, 60, 120, 300];
    }

    public function handle(PaymentGateway $gateway): void
    {
        $gateway->confirm($this->payload);
    }

    public function retryUntil(): \DateTime
    {
        // Stop retrying entirely after 20 minutes, regardless of attempt count
        return now()->addMinutes(20);
    }
}

A few things matter here:

  1. backoff() beats a fixed property when different failure types deserve different delays — you can inspect the exception in handle() or via failed() and adjust behavior accordingly.
  2. retryUntil() is a time-based ceiling that works alongside (not instead of) tries — whichever limit is hit first stops the retries. This matters for time-sensitive jobs like webhook processing, where retrying a payment confirmation two hours later is worse than giving up.
  3. Jitter avoids thundering herds. If a batch of jobs fails together (a dependency outage), identical backoff arrays mean they all retry at the same instant. Adding a small random offset — rand(0, 5) seconds — spreads the retry load.

Distinguishing retryable from permanent failures

Not every exception should trigger a retry. A validation error, a "resource not found," or an authentication failure will fail exactly the same way on attempt five as it did on attempt one — retrying just delays the inevitable and clutters your failed jobs table with noise.

Use $job->fail($exception) inside handle() to bypass remaining retries for exceptions you know are permanent:

public function handle(PaymentGateway $gateway): void
{
    try {
        $gateway->confirm($this->payload);
    } catch (InvalidCredentialsException $e) {
        // No point retrying — this will fail forever until someone fixes config
        $this->fail($e);
    } catch (RateLimitedException $e) {
        // Let Laravel's normal retry/backoff handle this one
        throw $e;
    }
}

This separation keeps your retry queue focused on failures that genuinely might succeed later, and routes permanent failures straight to your failure-handling logic (alerting, logging, manual review) without wasting worker cycles.

Handling exhausted retries with failed()

When a job exhausts all its retries, Laravel calls the failed() method if you define one. This is where you should:

  • Log enough context (job payload, exception, attempt count) to debug without re-running the job
  • Notify a human or a monitoring system for anything customer-facing
  • Trigger compensating logic if the job had partial side effects (e.g. release a reserved inventory item)
public function failed(\Throwable $exception): void
{
    Log::error('Payment webhook processing failed permanently', [
        'payload' => $this->payload,
        'exception' => $exception->getMessage(),
    ]);

    Notification::route('slack', config('services.slack.alerts'))
        ->notify(new QueueJobFailedNotification($this, $exception));
}

Best practices checklist

  • Set tries and backoff() deliberately per job type — a webhook and a report-generation job have very different tolerance for delay
  • Add retryUntil() for any job where "eventually" isn't good enough
  • Make jobs idempotent wherever possible, so a retry after a partial success doesn't cause duplicate side effects
  • Use $job->fail() for exceptions you know are non-retryable, rather than letting them burn through all attempts
  • Monitor your failed_jobs table (or a dashboard on top of it) — a growing failed-jobs count is an early warning sign, not just cleanup work
  • Use php artisan queue:retry deliberately after fixing a root cause, not as a blanket "retry everything" habit

Frequently Asked Questions

How many retries should I configure for a typical job? There's no universal number, but 3–5 tries with increasing backoff covers most transient failures without letting a job linger too long. For time-sensitive jobs, pair a lower tries count with retryUntil() so time, not just attempt count, bounds the retry window.

Does backoff() work with all queue drivers? Yes — Redis, database, SQS, and Beanstalkd all respect the backoff configuration, though the underlying delay mechanism differs (Redis and the database driver use delayed job scheduling internally; SQS uses its own visibility timeout and delay features).

What's the difference between tries and retryUntil()? tries counts attempts; retryUntil() sets a wall-clock deadline. Laravel stops retrying as soon as either limit is reached, so you can combine both — for example, "retry up to 5 times, but never later than 20 minutes after the first attempt."

Should every job be idempotent? Ideally yes, especially any job with external side effects like charging a card or sending an email. If true idempotency isn't feasible, at minimum track a unique operation ID so a retry can detect and skip work that already completed.

Key Takeaways

Retry logic is not a detail to leave on Laravel's defaults — it's part of your application's failure-handling design. Configure backoff() with increasing delays and jitter, bound retries with retryUntil() where timing matters, separate retryable from permanent failures explicitly with $job->fail(), and always implement failed() to log and alert rather than let exhausted jobs disappear silently into the failed_jobs table. Start by auditing your highest-traffic queue's current retry configuration this week — it's the fastest way to find where a naive default is quietly costing you reliability.

#laravel #queues #php #redis #backoff #job-processing
Share this article: