All posts

Where your queue jobs go to die under load

Published July 18, 2026 · by Majd Ghithan, written with the help of AI

Where your queue jobs go to die under load

The support ticket said "I never got my invoice email." I checked the logs. The job ran. It succeeded. failed_jobs was empty. Everything said the email went out. It did not.

That's the worst kind of queue bug: not a loud exception, but a job that quietly never finished the work it claimed to. Under normal traffic you never see it. Under load, jobs start vanishing, and the system keeps insisting everything is fine.

Here's how jobs actually disappear, and how I stop them.

A job doesn't "fail." It gets killed.

The mental model most people have is that a job either succeeds or throws an exception that lands in failed_jobs. But there's a third path, and it's the one that hurts: the worker process gets killed mid-job, before it can record anything at all.

Horizon kills a worker for two reasons, and both are silent by default:

  • Memory. The worker exceeds memory (default 128 MB) and Horizon restarts it. Whatever it was running just stops.
  • Timeout. The job runs longer than timeout and gets SIGKILL'd. No catch, no finally, no failed() hook. Gone.

At demo scale your jobs are small and fast, so neither ever triggers. At real scale, a report job loads 40,000 rows into memory, or an external API gets slow and your HTTP call hangs for 90 seconds. The worker dies, and because it was killed rather than throwing, nothing lands in failed_jobs.

Job failure rate as concurrency climbs
00.431110/s50/s120/s200/s
% dropped

That curve is the tell. Zero failures in testing, then a knee where memory pressure and slow dependencies start killing workers faster than they finish. The jobs aren't failing — they're being executed halfway and dropped.

The timeout chain has to be ordered

This is the single most common misconfiguration I fix. There are three timers, and if they're in the wrong order, retries eat each other.

  • retry_after (in config/queue.php) — how long before the queue assumes a job died and hands it to another worker.
  • timeout (the worker flag / Horizon supervisor) — how long before the process is killed.
  • The job's own $timeout property, if set.

The rule: timeout must be shorter than retry_after. If retry_after is 90 and timeout is 120, the queue releases the job for a second worker at 90 seconds while the first worker is still running it. Now the same job runs twice, concurrently. That's how a "send email" becomes two emails, or a charge becomes two charges.

// config/horizon.php — one supervisor
'supervisor-1' => [
    'connection' => 'redis',
    'queue' => ['default'],
    'balance' => 'auto',
    'maxProcesses' => 10,
    'memory' => 256,        // MB before restart — raise it for report jobs
    'timeout' => 60,        // MUST be < retry_after
    'tries' => 3,
    'backoff' => [10, 30, 60], // wait 10s, then 30s, then 60s between retries
],

backoff as an array gives you exponential-ish retries: don't hammer a struggling downstream service three times in three seconds — space the attempts out so it can recover.

Tell the job when to stop trying

tries => 3 is fine until a job is retried three times over an hour and the underlying action is no longer valid — the order was cancelled, the user was deleted. retryUntil is better than a raw count for anything time-sensitive:

public function retryUntil(): \DateTime
{
    return now()->addMinutes(10);
}

Now the job retries as many times as it can inside a 10-minute window, then stops. For a payment webhook or a notification, "keep trying for 10 minutes, then give up and alert" is almost always what you actually want, not "try exactly 3 times whenever."

The two configs that make failures visible

Everything above is prevention. This is the part that turns a silent drop into a loud, catchable failure:

Silent — job killed, nothing recorded
// worker killed at timeout
// no failed() hook fires
// failed_jobs stays empty
// support ticket is your only signal
Loud — explicit failure you can act on
public int $tries = 3;
public int $timeout = 55;

public function failed(\Throwable $e): void
{
    Log::error('Invoice job failed', [
        'user' => $this->user->id,
        'error' => $e->getMessage(),
    ]);
    // alert, mark record, compensate
}

Two things make the difference. First, set $timeout on the job below the worker timeout so the job throws a TimeoutExceededException you can catch, instead of getting SIGKILL'd with no trace. Second, actually implement failed() — it's the only place you get to react when all retries are exhausted.

The fix for vanishing jobs is almost never "add more retries." It's making the failure loud enough to see: order the timeout chain, cap memory sensibly, set a job-level timeout under the worker's, and implement failed(). Do that, and a dropped job stops being a support ticket three days later and becomes an alert you get in the same minute it happens.

🤖 Heads up: this post was drafted with AI. Spot something wrong or off?Edit it on GitHub & open a PR
Majd Ghithan

Majd Ghithan

Full-Stack Engineer & Tech Lead

More posts

Laravel finally resizes images for you

Laravel finally resizes images for you

Every Laravel app I've built that takes an avatar ended the same way: composer require intervention/image, wire up a facade, write a little service class, and hope the next dev…

July 26, 2026Read more
What actually breaks at 100,000 users (that never breaks in a demo)

What actually breaks at 100,000 users (that never breaks in a demo)

The first time I shipped a dashboard to a hundred thousand active users, nothing I had tested was what broke. Everything that failed had passed every check I ran. It worked on m…

July 25, 2026Read more
The indexes you're missing (and the one that's hurting you)

The indexes you're missing (and the one that's hurting you)

A client sent me a query that took 4.2 seconds. One WHERE, one ORDER BY, a table with three million rows. I added a single index and it dropped to 11 milliseconds. They asked if…

July 11, 2026Read more
Caching in Laravel without the stampede

Caching in Laravel without the stampede

We cached the homepage's "trending products" query for five minutes. It was our slowest query — about 900ms, a big aggregation across orders. Caching it took the homepage from s…

July 4, 2026Read more
Taking one endpoint from 800ms to 80ms

Taking one endpoint from 800ms to 80ms

The order-details endpoint took 800 milliseconds. Not broken, just slow enough that the app felt heavy everywhere it was used. The team's instinct was "the server needs more mem…

June 27, 2026Read more
The N+1 you can't see (it's hiding in your accessors)

The N+1 you can't see (it's hiding in your accessors)

I've fixed hundreds of N+1 queries. The easy ones are right there in the controller — a foreach with $order-customer inside it, obvious the moment you read the code. Those aren'…

June 20, 2026Read more
The Friday deploy that charged customers twice

The Friday deploy that charged customers twice

It was 4:40 on a Friday. The change was tiny — a one-line tweak to how we called the payment provider, plus a bump to the queue worker's timeout. Small, tested, reviewed. I depl…

June 13, 2026Read more
Code review people don't dread

Code review people don't dread

I once left forty-one comments on a junior's pull request. I was proud of it. Thorough, I told myself. The next day he barely made eye contact, and his next PR sat open for a we…

June 6, 2026Read more
Hiring a mid-level Laravel dev: the signals that actually matter

Hiring a mid-level Laravel dev: the signals that actually matter

The best hire I ever made failed my first question. I asked him to explain service containers and he stumbled, went quiet, then said "honestly I use them every day but I've neve…

May 30, 2026Read more
Saying no to a feature without being the 'no' person

Saying no to a feature without being the 'no' person

For about a year I was the engineer everyone learned to route around. Not because I was wrong, I was usually right, but because my answer to new ideas was a flat "no, that'll br…

May 23, 2026Read more
My first 90 days as a tech lead (and the habit I had to break)

My first 90 days as a tech lead (and the habit I had to break)

Three weeks into leading my first team, I stayed late to "help" by rewriting a junior's feature myself. It was faster my way. I pushed it, felt productive, went home. The next m…

May 16, 2026Read more
Breaking down the task that scares you

Breaking down the task that scares you

There's a specific kind of ticket that makes my stomach drop. Not the hard ones, hard is fine. It's the ambiguous ones. "Migrate billing to the new provider." "Add multi-tenancy…

May 9, 2026Read more
1:1s that aren't just status updates

1:1s that aren't just status updates

For my first few months as a lead, my 1:1s were thirty minutes of me asking "so, what are you working on?" and nodding at answers I already knew from standup. We both left sligh…

May 2, 2026Read more
Get the business logic out of your controllers

Get the business logic out of your controllers

I once opened a OrderController@store method that was 240 lines long. Validation, a discount calculation, three model writes, a Stripe charge, two emails, a Slack notification,…

April 25, 2026Read more
Form Requests are the most underrated thing in Laravel

Form Requests are the most underrated thing in Laravel

Most Laravel devs meet Form Requests once, in a tutorial, use them to hold a rules() array, and never look deeper. That's a shame, because a Form Request is the cleanest place i…

April 18, 2026Read more
Testing Laravel without mocking everything

Testing Laravel without mocking everything

I inherited a codebase once with 900 unit tests and no confidence. Every test mocked the repository, mocked the model, mocked the mailer, mocked the thing three layers down. The…

April 11, 2026Read more
Three Eloquent features that clean up your models

Three Eloquent features that clean up your models

The messiest Laravel model I ever wrote wasn't messy because of Eloquent. It was messy because I ignored the parts of Eloquent that exist specifically to keep it clean. The same…

April 4, 2026Read more
The migration that locked the table for eight minutes

The migration that locked the table for eight minutes

The deploy looked boring. One migration, adding a lastseenat column to the users table with a default of now(). I'd written a hundred like it. I ran it at 2pm on a Tuesday becau…

March 28, 2026Read more
Chasing a memory leak in a Laravel queue worker

Chasing a memory leak in a Laravel queue worker

The alert came in at 4am: one of our queue:work processes had been OOM-killed. The supervisor restarted it, it processed jobs for about forty minutes, and got killed again. It w…

March 21, 2026Read more