MySQL Lock Wait Timeout Exceeded in Laravel
Still retrying jobs after a MySQL lock wait timeout exceeded error? Here's how to find the real blocking query in Laravel and fix it for good.

MySQL's "Lock wait timeout exceeded; try restarting transaction" error shows up in Laravel apps the moment two processes fight over the same rows for too long, and the default fix most people reach for — just retry the job — only hides the real problem. I've hit this exact error on three different client projects this year, and every time the actual cause was different: a long-running transaction holding a lock on a queue job, an unindexed foreign key forcing a broader lock than necessary, or a batch update running inside a request that also tried to touch the same rows from a scheduled command at the same time. This post walks through how to actually find which one you've got and fix the cause, not just retry around it.
What "Lock Wait Timeout Exceeded" Actually Means
MySQL's InnoDB engine uses row-level locking to keep transactions consistent. When transaction A holds a lock on a row and transaction B tries to update that same row, B waits. innodb_lock_wait_timeout (90 seconds by default on most setups) is how long B will wait before MySQL gives up and throws the error instead of waiting forever.
The key thing people miss: this isn't a deadlock. A deadlock gets detected and resolved automatically in under a second. A lock wait timeout means something held a lock for 90 straight seconds — that's almost never a fast query. It's a transaction that's doing something slow while holding the lock open, or a transaction that never committed because of an exception, a dead queue worker, or a forgotten DB::beginTransaction() without a matching commit on an early return path.
In Laravel specifically, I see this most often in three places:
- A queue job wrapped in
DB::transaction()that also calls an external API or sends an email inside the transaction — the HTTP call is slow, the lock stays open the whole time - A scheduled command (
php artisan schedule:run) doing a bulk update on a table that a web request is also writing to lockForUpdate()used on a query that scans more rows than intended because of a missing index, locking way more than the one row you meant to lock
Finding What's Actually Holding the Lock
Before you can fix this, you need to see what's blocking what. Guessing wastes time — I've watched a junior dev on a client project add retry logic to a job for two days before we actually looked at what was holding the lock, which turned out to be an unrelated nightly import script.
Run this against your database while the problem is happening (or right after, InnoDB keeps recent lock info around briefly):
On MySQL 5.7 and earlier, swap performance_schema.data_lock_waits for information_schema.innodb_lock_waits, same idea. This tells you the actual blocking query, not just the waiting one — that's the query you need to fix, not the one that timed out.
The Real Fix, Not Just Retrying
Once you know which transaction is the slow one, the fix usually falls into one of these:
// Before: the whole external call sits inside the lockDB::transaction(function () use ($order) {$order->lockForUpdate();$order->status = 'processing';$order->save();$response = Http::post('https://payment-provider.test/charge', ['amount' => $order->total,]); // this can take 2-10 seconds, lock is held the entire time$order->payment_reference = $response->json('id');$order->save();});// After: lock only what's needed, do slow work outside the transaction$order->update(['status' => 'processing']);$response = Http::post('https://payment-provider.test/charge', ['amount' => $order->total,]);DB::transaction(function () use ($order, $response) {$order->lockForUpdate();$order->payment_reference = $response->json('id');$order->save();});
The actual lock-holding window drops from "however long the HTTP call takes" to a single-row update that commits in milliseconds. That's the pattern I'd reach for first in almost every case like this — not a longer timeout, not a smarter retry, just less work happening while the lock is open.
A few other things worth checking once you've isolated the slow transaction:
- Missing index on the WHERE clause —
lockForUpdate()on a query without a usable index locks every row it has to scan to find matches, not just the ones it returns. Add the index, and the lock shrinks to the rows you actually meant. - Queue job timeouts longer than the lock timeout — if a job's
$timeoutis 120 seconds but it's holding a row lock the whole time, you'll hit the 90-second default lock timeout before Laravel even kills the job. - Transactions spanning multiple HTTP requests — rare, but I've seen it: a transaction opened in middleware and never explicitly closed on an early
returnorabort()call.
Here's where I'll disagree with most Stack Overflow answers on this: bumping innodb_lock_wait_timeout up, or adding ->onQueue('low-priority') and a retry count, treats the symptom. Unless you've genuinely confirmed the long-held transaction is unavoidable — a legitimately heavy batch job that has to run, say — raising the timeout just delays the exact same problem to the next time traffic spikes, and now your users wait two minutes instead of ninety seconds before seeing the same failure.
Frequently Asked Questions
Does wrapping a queue job's retry in a database transaction make this worse?
Usually yes, if the job itself is what's slow. Laravel's job retry logic re-runs the whole handle() method, so if the transaction and the slow work are both inside it, every retry re-acquires the same lock and waits the same 90 seconds again before failing a second time.
Is this the same thing as a deadlock? No. A deadlock is two transactions each waiting on a lock the other holds, and MySQL detects and kills one of them in under a second. A lock wait timeout is one transaction waiting a full 90+ seconds for a lock that was simply held too long — the fix for each is different.
Should I just catch the exception and retry?
Catching Illuminate\Database\QueryException and retrying can be a reasonable safety net on top of an actual fix, not instead of one. If you're only ever retrying and never looking at what held the lock, you'll keep hitting this under load.
Does switching to SELECT ... FOR UPDATE SKIP LOCKED help?
Sometimes, specifically for queue-style "claim a row" patterns where you don't care which row you get — it skips already-locked rows instead of waiting on them. It won't help if the problem is a single row genuinely needed by two processes at once.
Key Takeaways
Find the actual blocking query with performance_schema.data_lock_waits before changing anything — guessing which transaction is slow wastes more time than the query itself takes to run. Move slow work (HTTP calls, emails, heavy computation) outside the DB::transaction() block so the lock window is as short as the database write actually needs, and add the missing index before reaching for a longer timeout. If you're still hitting this after narrowing the transaction and indexing the lookup, the next thing to check is whether a scheduled command and a web request are legitimately contending for the same rows at the same time of day — that's a scheduling fix, not a database one.


