Sitemap

Stop Laravel Queue Timeouts From Racing retry_after

8 min read4 days ago

--

Press enter or click to view image in full size
One attempt, four clocks. Each layer should stop the work before the next layer acts on it.

A Laravel queue job does not have one timeout. It can have a deadline inside an HTTP client, a timeout on the job class, a worker or Horizon timeout, and a queue reservation controlled by retry_after. These values govern different layers, but they act on the same execution.

When they disagree, a job can become visible to another worker while the first worker is still running. Now two processes may perform the same work at the same time. Idempotency still matters, but it should not be the excuse for an invalid timeout configuration.

This article targets Laravel 13, Horizon 6, PHP 8.3 or later, and a Redis queue connection. The specific numbers are examples, not universal defaults. The useful part is the ordering:

HTTP deadline < job timeout < Horizon timeout < retry_after

Each gap gives the next layer time to observe and stop the failed execution before Redis makes the reservation available again.

Press enter or click to view image in full size
The HTTP call fails first, then the job, then the Horizon worker, and only then does Redis make the reservation available again.

Understand what each clock controls

The request deadline belongs to the dependency call. If an export job contacts a reporting service, the HTTP client should stop waiting while the job still has time to handle the exception, log context, and let Laravel apply retry policy.

The job timeout limits one attempt. In Laravel 13, the #[Timeout] attribute can declare this on the job class. When a job-level timeout exists, it takes precedence over the timeout supplied to queue:work.

Horizon has its own supervisor timeout. Laravel’s Horizon documentation says this value must be greater than any job-level timeout. Otherwise, Horizon can terminate the process before the job’s own budget has elapsed. Horizon also requires its timeout to be a few seconds shorter than the queue connection’s retry_after value.

retry_after is not backoff. It is the time after which the queue connection considers a reserved job eligible to be tried again if the worker has not deleted or released it. Backoff starts after a failed attempt is released. Mixing those concepts often produces configurations that look cautious but permit concurrent attempts.

Amazon SQS is the notable exception to the retry_after configuration discussed here. SQS manages visibility timeout in its own service settings. Do not copy a Redis timeout budget into SQS configuration without translating it to that model.

Give dependency calls the smallest deadline

Consider a job that downloads account data and writes a deterministic local export:

<?php

declare(strict_types=1);

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\FailOnTimeout;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;

#[Timeout(75)]
#[FailOnTimeout]
final class BuildAccountExport implements ShouldQueue
{
use Queueable;

public int $tries = 3;

public function __construct(public readonly int $accountId)
{
}

/**
* @return array<int, int>
*/
public function backoff(): array
{
return [15, 60];
}

public function handle(): void
{
$payload = Http::baseUrl((string) config('services.reports.url'))
->withToken((string) config('services.reports.token'))
->connectTimeout(5)
->timeout(20)
->get("/accounts/{$this->accountId}/export")
->throw()
->json();

Storage::disk('local')->put(
"exports/accounts/{$this->accountId}.json",
json_encode($payload, JSON_THROW_ON_ERROR),
);
}
}

The HTTP client gets five seconds to establish a connection and twenty seconds to wait for a response. The job gets seventy-five seconds for the whole attempt. That leaves room for application work and failure handling instead of allowing a socket operation to consume the entire worker budget.

The output path is based on the account ID, so a retry replaces the same export rather than creating another file. That makes this example locally repeatable. It does not make every remote request safe. A retried POST that creates a charge or sends a notification needs a documented idempotency contract at the receiving system.

#[FailOnTimeout] makes a worker-level timeout final even though the job permits three attempts. This is appropriate when a forced termination suggests the attempt may have left an uncertain partial effect that needs inspection. Without that attribute, a timeout consumes an attempt and Laravel can release the job for another attempt when retries remain. A normal HTTP timeout is different: it throws an exception inside the running process, so the queue retry policy can still apply.

The PHP process also needs the pcntl extension for Laravel's job timeouts. Some blocking I/O libraries do not respect PHP signals promptly, which is another reason to configure timeouts in the client library itself.

Keep the Redis reservation outside the worker deadline

The queue connection must reserve the job longer than any valid worker is allowed to execute it:

<?php

declare(strict_types=1);

return [
'default' => env('QUEUE_CONNECTION', 'redis'),

'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 120),
'block_for' => 5,
'after_commit' => false,
],
],

'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

With retry_after set to 120 seconds, Redis should not offer the reservation to another worker while the seventy-five-second job budget is valid. The remaining gap is not time available for useful work. It is a safety margin for process termination, scheduling delay, and cleanup.

Do not set retry_after to the sum of all retries and backoff periods. It applies to one reserved attempt. Also do not reduce it because most jobs finish quickly. It must cover the longest legitimate attempt on that connection. If one queue mixes two-second notifications with fifteen-minute media work, separate those workloads onto connections or workers with budgets that fit them.

A larger value is not free protection. If a worker disappears without releasing a reservation, Redis waits until retry_after before the job becomes available again. The value balances duplicate-execution risk against recovery delay. Choose it from the maximum intended attempt duration plus a measured margin, then monitor actual runtimes.

Get Udit Rawat’s stories in your inbox

Join Medium for free to get updates from this writer.

Record those decisions beside the queue configuration. A future engineer should know whether a gap is deliberate, which workload established the upper bound, and what evidence would justify changing it. Otherwise, round numbers tend to drift independently.

Press enter or click to view image in full size
When retry_after is shorter than the worker timeout, Worker B picks up the job while Worker A is still running it.

Put Horizon between the job and Redis

Horizon needs a timeout greater than the job’s seventy-five seconds and shorter than Redis’s 120 seconds. This supervisor assigns the export queue a ninety-second limit:

<?php

declare(strict_types=1);

return [
'use' => 'default',
'prefix' => env('HORIZON_PREFIX', 'app_horizon:'),
'middleware' => ['web'],
'waits' => ['redis:exports' => 60],
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'recent_failed' => 10080,
'failed' => 10080,
'monitored' => 10080,
],
'silenced' => [],
'metrics' => ['trim_snapshots' => ['job' => 24, 'queue' => 24]],
'fast_termination' => false,
'memory_limit' => 64,

'defaults' => [
'exports' => [
'connection' => 'redis',
'queue' => ['exports'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'minProcesses' => 1,
'maxProcesses' => 4,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 256,
'tries' => 3,
'timeout' => 90,
'nice' => 0,
],
],

'environments' => [
'production' => [
'exports' => [],
],
],
];

The important sequence is now 20 seconds for an HTTP response, 75 seconds for the job, 90 seconds for Horizon, and 120 seconds for Redis reservation expiry. Horizon remains able to identify a worker as hanging, while Redis keeps the job reserved long enough for that worker to be terminated first.

The Horizon tries value and the job's $tries property are not additive. Job-level configuration takes precedence where Laravel documents that behavior. Keep the values aligned or choose one clear owner so operators do not infer a retry count that the worker will never use.

Process management adds one more shutdown concern. During deployment, the process manager must allow workers enough time to finish their longest job or terminate according to the chosen policy. Laravel’s queue documentation specifically says Supervisor’s stopwaitsecs should exceed the number of seconds consumed by the longest-running job. This shutdown grace period does not replace the timeout chain above.

Make configuration drift fail a test

Timeout values often begin in a safe order and drift apart as one team changes a job while another changes infrastructure. A small Pest test can turn the ordering into an executable rule:

<?php

declare(strict_types=1);

use App\Jobs\BuildAccountExport;
use Illuminate\Queue\Attributes\Timeout;

it('keeps the export timeout budget in a safe order', function (): void {
$reflection = new ReflectionClass(BuildAccountExport::class);
$attributes = $reflection->getAttributes(Timeout::class);

expect($attributes)->toHaveCount(1);

$jobTimeout = (int) $attributes[0]->getArguments()[0];
$horizonTimeout = (int) config(
'horizon.defaults.exports.timeout',
);
$retryAfter = (int) config(
'queue.connections.redis.retry_after',
);

expect($jobTimeout)
->toBe(75)
->toBeLessThan($horizonTimeout)
->and($horizonTimeout)
->toBeLessThan($retryAfter);
});

The test reads the job attribute and the actual application configuration rather than repeating all three values in assertions. It will fail if the job grows to ninety-five seconds while Horizon stays at ninety, or if an environment-independent config change reduces retry_after below the supervisor timeout.

Environment variables can still override configuration in deployment. Run the same assertion, or an equivalent startup check, against built configuration in the target environment. Remember that php artisan config:cache changes how runtime environment values are loaded. Validate after configuration is built, not only against a developer's .env file.

This test proves ordering, not behavior under a real forced kill. An integration check with the actual Redis, Horizon, and process manager is still useful for a deliberately blocking test job. Keep that job away from production queues, and assert from logs or durable state that a second worker does not begin before the first has been terminated.

Press enter or click to view image in full size
The Pest test reads the real job attribute and config values, so drift in any one layer fails the build.

Choose a budget from the inside out

Start with the dependency call. Set connection and response deadlines from the operation’s service-level expectation, not from the queue’s reservation. Then allow the job enough time to handle that call and complete bounded local work. Put the Horizon timeout above every job-level timeout assigned to that supervisor. Finally, set Redis retry_after above Horizon with a deliberate safety margin.

Before deploying a queue with time-bound work, check:

  • Every network client has its own connection and response timeout.
  • The maximum dependency deadline is lower than the job timeout.
  • Every job timeout is lower than its Horizon supervisor timeout.
  • Every Horizon timeout is lower than the Redis connection’s retry_after.
  • Long and short workloads use separate queues or supervisors when one budget cannot fit both.
  • Side effects remain idempotent because workers can still crash at arbitrary points.
  • Deployment shutdown grace covers the longest intended job.
  • Monitoring records runtime percentiles, timeouts, retries, and failed jobs before numbers are tuned.
  • A test or startup check rejects timeout ordering drift.

Do not add generous timeouts everywhere and call the system safe. A job that genuinely needs twenty minutes may belong in a checkpointed workflow with smaller resumable steps. Timeout budgeting controls one attempt. It does not make unbounded work observable, recoverable, or cheap to retry.

Sources

Udit Rawat is a Senior Software Engineer with more than 10 years of experience and co-founder of Moosky AI.

--

--

Udit Rawat
Udit Rawat

Written by Udit Rawat

Co-founder of Moosky AI. Writing about AI agents, software engineering, SaaS, automation, agentic workflows, and building real AI products.