Background Job Processing in Laravel in Depth: Queues, Retries, and Idempotency
A deep production guide to Laravel queues: designing small jobs, exponential backoff and jitter to prevent retry storms, idempotency for critical operations, and non-silent failure. With code examples.
It's 3 a.m., and the payment platform collapses. No attack, no server failure. The cause is a single job in the queue: a call to an external payment gateway that lagged for a few seconds, so the job failed, retried, then thousands of other jobs failed with it all at once, flooding the database with deadlocks. This is a "retry storm," one of the most dangerous things facing queue systems in production. The irony is that queues exist to protect your app, but when naively designed they become the source of the disaster itself. Let's build queues that don't collapse.
Why Queues at All?
The basic idea is simple: any slow work whose result the user does not need to wait for — sending an email, processing an image, calling an external API — is moved out of the HTTP request cycle into a "job" executed in the background by a "worker." The result: the app stays responsive, and heavy work is done away from the user's view. But this design opens a new door: what happens when a job fails? Here begins the difference between an amateur queue and a production one.
The First Rule: Keep Your Jobs Small and Single-Responsibility
The biggest architectural mistake is the "monster" job that does everything: validates, creates an invoice, debits a balance, and sends a notification, in one method. When it fails at the fourth step, it retries from the first, repeating the first three steps with their side effects. The rule: one job does one thing. Split the large operation into a chain of small jobs (validate, create, notify), each retryable, testable, and observable on its own. Small jobs make retrying cheap and safe.
Retries and Backoff: What Prevents the Storm
Laravel provides automatic retries via the tries property, but the default setup is a starting point, not an end. The problem is that repeated immediate retries are the fuel of the "storm." The solution is backoff. The simplest is fixed, but the most powerful is "exponential": double the delay after each attempt, to give the external service time to recover:
class ProcessPayment implements ShouldQueue
{
use Queueable;
public $tries = 4;
// Graduated delay: 5 seconds, then 15, then 60
public function backoff(): array
{
return [5, 15, 60];
}
}When attempts exceed the number of array values, Laravel uses the last value for the rest. But a hidden risk remains: if thousands of jobs failed at the same moment, they will all retry at the same moment too — a synchronized storm. The professional solution is adding "jitter": a small randomness on the delay that scatters the attempts and breaks the synchronization. And to deal with a flaky service, use the ThrottlesExceptions middleware that temporarily halts calls after a number of errors.
The Most Important Rule of All: Idempotency
Here is the essence of the article. In distributed systems, you must assume every job may execute more than once — due to retries, or a worker killed mid-work then the job returned to the queue. The common cause: misconfiguring retry_after versus the worker timeout. If a job is killed and re-queued, it will run twice unless your design prevents duplicates. Imagine a job that charges a customer's card executed twice: a double charge, and an angry customer.
"Idempotency" means that executing the same operation several times produces the same effect as if it ran once. The practical way: store an "operation key" in durable storage before performing the side effect, and check it at the start:
public function handle(): void
{
// Check first: has this operation been processed before?
$exists = ProcessedOperation::where(
'idempotency_key', $this->paymentId
)->exists();
if ($exists) {
return; // Already processed — exit safely
}
// Perform the side effect inside a transaction
DB::transaction(function () {
$this->chargeCustomer();
ProcessedOperation::create([
'idempotency_key' => $this->paymentId,
'processed_at' => now(),
]);
});
}Most importantly, the guarantee lives in durable storage (a unique constraint in the database, or an idempotency key supported by the external provider), not in memory nor in a "best-effort" log. And if the external API supports an "Idempotency-Key," pass it so it recognizes duplicate attempts. Laravel also offers the ShouldBeUnique contract to prevent scheduling multiple copies of the same job in the first place.
Transaction Boundaries: Dispatch After Commit
A subtle production trap: if you dispatch a job that depends on data inside a database transaction before it is committed, a fast worker may pick it up before the data is actually written, finding it nonexistent. The solution is using dispatchAfterResponse for light jobs, or more safely: make the dispatch happen after the commit via afterCommit, either in the connection config or explicitly:
ProcessPayment::dispatch($payment)->afterCommit();This ensures the worker does not start before the data it depends on is actually available in the database.
Non-Silent Failure: The Most Neglected Danger
When a job fails finally after exhausting its attempts, Laravel stores it in the failed_jobs table with its payload, error message, and stack trace — a treasure for diagnosis. But the bigger danger is not failure, but "silent" failure: an exception swallowed into a log instead of becoming a durable business state. Imagine a job syncing an invoice with an accounting system that fails, becoming a line in a log no one sees, while the system thinks the sync happened. The golden rule: make real failure visible. Implement the failed method to catch the final failure and take action (an alert, or compensation), and monitor the failed-job count with alerts that have a responsible owner:
public function failed(?Throwable $exception): void
{
// Runs after all attempts fail
Notification::route('slack', config('alerts.ops'))
->notify(new JobFailedAlert($this->paymentId, $exception));
// Record a durable business state for later reconciliation
PaymentReconciliation::create([
'payment_id' => $this->paymentId,
'status' => 'failed',
]);
}Separation and Monitoring: Multiple Queues, Not One
A common scaling mistake: mixing everything in one queue. Blending heavy imports with critical billing work and compute-heavy jobs makes latency unpredictable: a huge import job may delay an urgent payment notification. The rule: separate workloads into dedicated queues with priorities and separate workers (critical, imports, compute). And with Laravel Horizon (for the Redis driver), you get a monitoring dashboard showing backlog depth, failure rate, and runtime distribution. Monitor the critical metrics: queue depth, failure rate (a spike means a regression), latency at the 95th and 99th percentiles, and worker restarts (meaning a memory leak or oversized jobs).
The practical rule that sums up all of the above: do not design your queue on the assumption of success, but on the assumption of failure. A good job is small, safely retryable because it is idempotent, backs off gradually with jitter so as not to cause a storm, and fails audibly, not silently. Queues at their core are not a "fire and forget" tool, but a system requiring conscious design for every failure path. Whoever builds with this awareness sleeps soundly when the payment gateway lags at 3 a.m. — because their system was designed for exactly that moment.
Was this article helpful?
Newsletter
Enjoyed this?
Subscribe and get every new article and news post straight to your inbox.