Why the same job ran twice: timeout vs retry_after
This is one of the least visible queue misconfigurations, because nothing about it looks wrong from inside the job. There's no exception, no failed_jobs row, no log entry pointing at the cause. The symptom is purely external — a side effect that happened more times than it should have — and the cause is a mismatch between two settings that live in completely different files and rarely get looked at together.
ON THIS PAGE
Two separate mechanisms, easy to conflate
A job has a $timeout — how long it's allowed to run before Laravel kills the process:
class ProcessLargeExport implements ShouldQueue
{
public $timeout = 120; // seconds
}
A queue connection has a retry_after — how long the queue driver waits before assuming a job that hasn't been explicitly deleted from the queue must have failed, and releasing it back on for another worker to pick up:
// config/queue.php
'redis' => [
'driver' => 'redis',
'retry_after' => 90,
],
These sound like they're solving similar problems, but they're enforced by entirely different mechanisms, on entirely different clocks, and neither knows about the other's value unless you explicitly line them up.
The failure sequence
Set retry_after lower than a job's actual runtime, and here's what happens, concretely:
- Worker A picks up
ProcessLargeExport, which genuinely takes 100 seconds to complete. - At the 90-second mark, the queue driver's
retry_afterexpires. As far as the driver is concerned, this job has been "out" for its full allowed window without being deleted — so it assumes something went wrong and puts the job back on the queue. - Worker B, sitting idle, immediately picks up that same job and starts running it — while Worker A is still on second 91 of its original run, still processing.
- Both workers now run
handle()to completion, independently, for the same job.
If that job sends an email, the customer gets it twice. If it charges a card, the customer gets charged twice. Neither worker errored. Neither retry count went up in a way that looks abnormal. The failed_jobs table stays empty, because nothing failed — two things simply succeeded when only one was supposed to.
The fix is one config value, stated plainly in Laravel's own docs
retry_after must be set comfortably longer than the slowest job that will ever run on that connection — not the typical case, the worst realistic case:
// config/queue.php
'redis' => [
'driver' => 'redis',
'retry_after' => 150, // comfortably above the 120s job timeout
],
A reasonable rule of thumb: retry_after should exceed your longest job's $timeout by a meaningful margin, not just a couple of seconds — network hiccups, a slow external API call, or a moment of server load can all push actual runtime past the "normal" case, and the margin is what protects you when that happens.
The worker's --timeout flag matters too
queue:work also accepts its own --timeout flag, and the effective timeout for a job is whichever is more restrictive — the job's own $timeout property or the worker's --timeout flag:
php artisan queue:work --timeout=60
If a job declares $timeout = 120 but the worker is started with --timeout=60, the worker kills the job at 60 seconds regardless of what the job class says. This is easy to lose track of because the job-level setting lives in a PHP file and the worker-level setting lives in a Supervisor config or a deploy script — two files that don't usually get reviewed side by side, which is exactly how a mismatch like this survives for a long time without anyone noticing.
A related but distinct problem: WithoutOverlapping
The timeout/retry_after mismatch causes duplicate runs of the same queued instance. There's a separate, related problem worth distinguishing: preventing two different dispatches of a conceptually-the-same job from running concurrently — say, two SyncInventory jobs for the same warehouse, dispatched moments apart by two different triggers. That's what the WithoutOverlapping middleware solves, keyed on whatever value you choose:
public function middleware(): array
{
return [new WithoutOverlapping($this->warehouseId)];
}
This is a different mechanism solving a different problem — worth knowing about, but it won't fix a retry_after mismatch, and a correctly configured retry_after won't substitute for it if genuinely concurrent dispatches for the same logical resource are possible.
Checklist before shipping a job that takes real time
- Does
retry_afteron this job's queue connection comfortably exceed the job's actual worst-case runtime, not just its typical runtime? - Does the worker's
--timeoutflag (in Supervisor/systemd config) match or exceed what the job class itself declares, so one isn't silently overriding the other to a lower value? - If this job has an external side effect (email, charge, API call) that shouldn't happen twice, is there an idempotency check in
handle()as a second line of defense, independent of getting the timing config right? - Are you monitoring for jobs that run unusually long, rather than only for jobs that explicitly fail — since this failure mode never touches
failed_jobsat all?