laravelmastery
SnippetsArticlesSeriesNews

Queues in production · Lesson 2 of 3

Queues in production · Lesson 2

Retries, backoff and timeouts

When it goes wrongQueues

The action dispatched the job and returned. A payment provider times out. The job should try again, with a pause, and then stop. An instant retry hammers the provider. An infinite retry hides a bug.

Video

01 Wait longer between attempts

$tries caps the attempts. backoff() is the delay before each retry, in seconds. $timeout is how long handle() may run.

MarkOrderPaid.phpPHP
class MarkOrderPaid implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;

    public int $timeout = 60;

    public function backoff(): array
    {
        return [15, 60, 180];
    }
}

The queue connection’s retry_after must be longer than $timeout. If Redis retries the job after 60 seconds while handle() is still running, a second worker starts the same order. Set retry_after to 90 when the timeout is 60.

Throw when the provider fails and you want a retry. Return when the order is already paid: that is success, not a failure, and it is the concern of Make the job safe to run twice, later in this series.

02 A decline is not a timeout

A timeout means you do not know whether the card was charged. Throw, and let backoff() wait. A decline is an answer. Record it and return, or the same card is offered on every remaining try.

Example.phpPHP
class Example
{
    public function handle(PaymentGateway $gateway): void
    {
        $order = Order::query()->findOrFail($this->orderId);
        $result = $gateway->charge($order);

        if ($result->timedOut()) {
            throw new PaymentTimeout($order->id);
        }

        if ($result->declined()) {
            $order->forceFill(['status' => 'failed'])->save();

            return;
        }

        $order->forceFill(['status' => 'paid'])->save();
    }
}

The throw uses one of the five tries. The return does not. Laravel deletes the job after a normal return, and the failed status is the record a person can see. Do not throw for a decline unless you want the provider called again.

backoff() returns three delays and the job allows five tries. After the list runs out, Laravel keeps using the last delay, 180 seconds, for the tries that remain. You do not need a number for every try. You do need retry_after on the Redis connection to stay above the 60 second timeout, or a slow charge is started twice.

Recap

You can retry a timeout with a pause, and record a decline without burning the rest of the tries.

  • $tries caps attempts. backoff() is the wait. $timeout is how long handle() may run.
  • retry_after on the connection must be longer than $timeout.
  • Throw when you do not know if the card was charged. Return when the provider declined.

Check yourself · 3 questions

Question 1 of 3handle() is still running at 60 seconds and retry_after is 60. What can start?

A timeout waits, then tries again. Next, the record left when the tries run out.

← PreviousDispatch from actions, not controllers

On this page

  • Wait longer between attempts
  • A decline is not a timeout
  • Recap
  • Check yourself

Queues in production

  • 1 Overview

Hand the work off

  • 2 Keep the request short
  • 3 The job class
  • 4 Dispatch from actions, not controllers

When it goes wrong

  • 5 Retries, backoff and timeouts
  • 6 Failed jobs you can replay
  • 7 Unique jobs and WithoutOverlapping
  • 8 Batches and then()

Run it for real

  • 9 Make the job safe to run twice
  • 10 Horizon in production

Queues in production

Lessons

  1. 01Overview

Hand the work off

  1. 02Keep the request short
  2. 03The job class
  3. 04Dispatch from actions, not controllers

When it goes wrong

  1. 05Retries, backoff and timeouts
  2. 06Failed jobs you can replay
  3. 07Unique jobs and WithoutOverlapping
  4. 08Batches and then()

Run it for real

  1. 09Make the job safe to run twice
  2. 10Horizon in production

Get the Friday email

One snippet and the week’s Laravel news, in your inbox every Friday morning. Unsubscribe whenever you like.

laravelmastery

Practical Laravel learning for working developers, made in the UK.

Learn

  • Snippets
  • Articles
  • Series

Stay current

  • News
  • Friday email
  • RSS feed

Company

  • About
  • Privacy
  • Terms
© 2026 Laravel Mastery. Independent and not affiliated with Laravel Holdings Inc.Designed and built by Half Shell Studios

We remember the videos you watch, where you left off, and which series lessons you mark as read, against this connection’s address. Nothing is stored until you agree.