Updated June 2026. Tested on Laravel 13 and PHP 8.4. Part of the Techalyst queue series. Builds on Queue Job Batching in Laravel.
Batches are great until you dispatch a really big one. Push thousands of jobs into a single batch and you can deadlock your own queue, with workers throwing lock wait timeouts and even retrying jobs that already finished. Here is why, and how to dispatch large batches without the pain.
The problem
Imagine dispatching a batch of thousands of jobs at once.
Bus::batch($thousandsOfJobs)->dispatch();
When you dispatch, Laravel creates a row in the job_batches table and locks that row while it pushes all the jobs to the queue. With thousands of jobs, that dispatch takes a while, and the row stays locked the whole time.
Meanwhile, fast workers start picking up the early jobs before dispatch has even finished. When a worker completes a job, it tries to update the batch row (to decrement the pending count), but that row is still locked by the process busy dispatching the rest. So the workers wait. Pile up enough of them and your queue stalls, and the database eventually gives up.
SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction.
The cruel part: the database killed the worker's batch update, not the job. The job itself completed fine. But the worker sees the failed query, treats the job as failed, and retries work that already succeeded.
The fix: do not dispatch a huge batch in one go
The root cause is one process holding the batch lock too long. Keep each dispatch short, so the lock is never held long enough to collide with workers.
Add jobs to the batch in chunks rather than all at once. Create the batch with a small initial set, then add the rest in manageable chunks, so each add is quick.
use Illuminate\Support\Facades\Bus;
$batch = Bus::batch([])->name('Process Records')->dispatch();
collect($records)
->chunk(500)
->each(fn ($chunk) => $batch->add(
$chunk->map(fn ($record) => new ProcessRecord($record))->all()
));
Each add() locks the row only briefly, so workers get their turn to update it in between.
A common pattern is to dispatch a single "loader" job whose only purpose is to add the rest of the work to the batch, off the web request, in chunks. That keeps your controller fast and the lock contention low.
Slow the workers down at the start by putting a short delay on the batched jobs, so workers do not pounce on the first jobs while dispatch is still going.
$batch->add(
collect($chunk)->map(fn ($r) => (new ProcessRecord($r))->delay(now()->addSeconds(10)))->all()
);
Wrapping up
A huge single batch dispatch holds the job_batches row locked for too long, and workers updating that row time out, throwing Lock wait timeout exceeded and retrying jobs that actually succeeded. Avoid it by adding jobs to the batch in chunks (often from a loader job) so each lock is brief, and optionally delaying the batched jobs so workers do not start before dispatch finishes. Small, quick dispatches keep the lock contention away.
More in the series: queue job batching in Laravel and removing jobs of a specific type from a queue. Questions welcome below.
All comments ()
No comments yet
Be the first to leave a comment on this post.