Skip to content
PRSINDIA

Engineering

Laravel queues in production: four failure modes nobody warns you about

The tutorial gets you to a working job class. These are the four things that broke for us at 40,000 jobs a day.

PRS Admin Updated on 7 min read

Every Laravel tutorial ends the same way: create a job, implement ShouldQueue, run php artisan queue:work, celebrate. That code works perfectly at ten jobs a day. At forty thousand a day, across a payment flow, a bulk-import pipeline and an export queue, it breaks in four specific ways. All four cost us something real. Here they are, in the order they hurt.

Failure 1: the job that runs before its data exists

This one produced a genuinely baffling bug report: a small percentage of orders were sending "order confirmed" emails that said the order did not exist. The stack trace was ModelNotFoundException inside the job.

The cause is the interaction between database transactions and the queue. Consider:

DB::transaction(function () use ($cart) {
    $order = Order::create([...]);
    OrderItem::insert($cart->lines());

    SendOrderConfirmation::dispatch($order); // <- fires NOW
    ChargeRazorpay::dispatch($order);
});

dispatch() pushes the job onto Redis immediately. Redis does not participate in your MySQL transaction. A worker on another machine can pick that job up, deserialise $order by primary key, and query for it — all before the transaction has committed. The row is not visible yet. The job explodes.

It is a race, so it fails maybe one time in three hundred, which is exactly bad enough to survive QA and reach production.

The fix is one line in config/queue.php, per connection:

'redis' => [
    'driver' => 'redis',
    'connection' => 'default',
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => 300,
    'block_for' => 5,
    'after_commit' => true,   // <- this
],

With after_commit, Laravel holds dispatched jobs until the surrounding transaction commits, and discards them if it rolls back. That second half matters just as much: without it, a rolled-back transaction still leaves a ChargeRazorpay job on the queue for an order that no longer exists.

If you cannot flip it globally, do it per dispatch with SendOrderConfirmation::dispatch($order)->afterCommit().

Failure 2: retries that are not idempotent

Laravel's default behaviour on failure is to retry. This is a good default and a loaded gun. A job that charges a card, sends an SMS or posts to a partner API is not safe to run twice, and there are more ways for it to run twice than you would think:

  • the job threw halfway through, after the side effect
  • the worker was SIGKILLed mid-job by a deploy or an OOM killer
  • retry_after elapsed while the job was still legitimately running, so the queue handed the same payload to a second worker

That last one is the sneaky one and it deserves its own rule:

retry_after in your queue config must be strictly greater than the worker's --timeout. If a job can run for 90 seconds but retry_after is 60, the queue will release it to a second worker while the first is still working. You now have two workers charging the same card.

Beyond getting the timing right, the side effect itself has to be defended. For anything that touches money we do two things. First, an idempotency key at the boundary — Razorpay, Stripe and most serious payment APIs accept one and will return the original response rather than charging again:

class ChargeRazorpay implements ShouldQueue
{
    public int $tries = 3;
    public int $timeout = 30;
    public array $backoff = [10, 60, 300];

    public function __construct(public Order $order) {}

    public function handle(Razorpay $rzp): void
    {
        // Deterministic per order — a retry reuses the same key.
        $key = 'order_'.$this->order->id.'_attempt';

        $payment = $rzp->charge(
            amountPaise: $this->order->total_paise,
            idempotencyKey: $key,
        );

        $this->order->forceFill([
            'payment_id' => $payment->id,
            'paid_at' => now(),
        ])->save();
    }
}

Second, for jobs where the external system offers no such key, we guard with ShouldBeUnique or a WithoutOverlapping middleware keyed on the entity, so a duplicate dispatch is dropped rather than executed.

The general principle, and the one to actually internalise: write the state change and the side effect so that running the job twice produces the same result as running it once. Check-then-act at the top of handle() (if ($this->order->paid_at) return;) is cheap insurance and we now treat its absence as a review blocker on any job with an external effect.

Failure 3: the worker that quietly eats the box

A queue worker is a long-lived PHP process. That is the whole point — you skip the framework bootstrap on every job — and it is also why every leak in your application accumulates. Static caches, the query log, resolved container singletons holding references, an ever-growing Eloquent model collection: none of it is freed between jobs.

We had an import worker that started at 90 MB and reached 1.2 GB over about six hours, at which point the kernel's OOM killer took it out — mid-job, which then triggered failure mode 2.

Three defences, all cheap:

  1. Bound the worker's life. Never run a bare queue:work. Use --max-jobs=1000 --max-time=3600 --memory=512. The worker exits cleanly at whichever limit hits first, and Supervisor or Horizon starts a fresh one. Restarting a PHP process is nearly free; leaking for six hours is not.
  2. Never load a big table into memory. Order::all() in a job is how you get a 1 GB worker. Use chunkById() — and specifically chunkById, not chunk, because chunk with an OFFSET skips rows when the job is also mutating the rows it is paging through, which silently drops records.
  3. Restart workers on deploy. php artisan queue:restart in your deploy script. Without it, your workers keep executing the old code from before the deploy, because the class definitions are already loaded in memory. This produces the world's most confusing bug: "I fixed that job an hour ago and it is still failing the old way."

Failure 4: starvation — one slow queue blocking everything

An ops user kicked off a bulk export that fanned out into roughly 50,000 jobs. Those jobs went onto default. So did payment webhooks. For the next forty minutes, every payment confirmation sat behind an export queue, and customers watched a spinner.

The mistake is treating the queue as a single pipe. It is not — it is a set of pipes, and you get to decide who waits behind whom. We now split by latency requirement, not by feature area:

// config/horizon.php
'environments' => [
    'production' => [
        'critical' => [            // webhooks, payments, OTP
            'connection' => 'redis',
            'queue' => ['critical'],
            'balance' => 'auto',
            'minProcesses' => 2,
            'maxProcesses' => 10,
            'timeout' => 30,
            'tries' => 5,
        ],
        'default' => [            // emails, notifications
            'connection' => 'redis',
            'queue' => ['default'],
            'balance' => 'auto',
            'maxProcesses' => 8,
            'timeout' => 60,
        ],
        'bulk' => [               // exports, imports, reports
            'connection' => 'redis',
            'queue' => ['bulk'],
            'balance' => 'auto',
            'maxProcesses' => 4,
            'timeout' => 900,
        ],
    ],
],

The critical pool has its own processes. A bulk job can never occupy them. Note also that the timeouts differ per pool, which is only possible because they are separate supervisors — a single worker cannot have both a 30-second timeout for webhooks and a 900-second one for exports.

If you are not on Horizon, the same idea works with priority ordering: php artisan queue:work --queue=critical,default,bulk drains critical completely before touching default. That protects latency but does not isolate capacity, so a flood of critical jobs still starves the rest. Separate supervisors are strictly better.

The things we now check before any queue goes live

  • after_commit is on, or every dispatch inside a transaction uses ->afterCommit().
  • retry_after > worker --timeout, verified, per connection.
  • Every job with an external side effect is idempotent, and says so in a comment explaining why.
  • Workers run with --max-time and --memory.
  • queue:restart is in the deploy script.
  • Queues are split by latency requirement, with separate process pools.
  • failed_jobs is monitored — an alert on row count, not a table someone remembers to look at.
  • Queue depth and oldest-pending-job age are on the dashboard. Depth alone lies; a queue of 5 jobs where the oldest is 20 minutes old is an outage.

None of these are clever. All four incidents above were caused by not doing one of them.

Updated July 2026: Laravel's improved queue backoff and per-job middleware have made two of the four failure modes above far easier to contain — we now reach for job-level rate limiting before touching any global Horizon config.

Written by

PRS Admin

Building software at PRS India.

Keep reading

All articles

Engineering

Postgres or MySQL in 2026? An honest answer for Indian startups

Read it

Engineering

Core Web Vitals for ecommerce: the three fixes that moved the needle

Read it

Engineering

Offline-first isn't a feature, it's an architecture — a driver app post-mortem

Read it