Deadlocks happen when two transactions each hold a lock the other needs, and neither will release until it gets what it’s waiting for. Salesforce’s lock detector eventually kills one of them, but the user sees an error and the work is lost. Five rules make deadlocks rare.
1. Lock parents before children, always in the same order
The classic deadlock recipe is two transactions touching the same parent/child pair in opposite order:
Transaction A: locks Account 001, then tries Contact 003 (held by B)
Transaction B: locks Contact 003, then tries Account 001 (held by A)
Each waits forever for the other. Both transactions must take locks in the same canonical order — always parent first, always child second. If your code consistently goes Account → Contact → Case, no two transactions can deadlock at this layer.
A specific consequence: when you insert a Child record, Salesforce locks the parent for the duration. Inserting Contacts that point at the same Account from concurrent transactions will serialize at the Account row.
2. Sort records by Id before bulk DML
For multi-record DML, the platform takes locks in whatever order it processes the list. Two concurrent transactions hitting overlapping sets of records can deadlock if they process them in different orders:
// BAD — order depends on caller, may be unsorted
update mixedList;
// GOOD — deterministic order matches whatever the other tx will use
mixedList.sort(); // sorts by Id
update mixedList;
Both transactions now process records in the same canonical order. The contended row goes to whichever transaction grabs it first; the other waits for the lock — but doesn’t deadlock.
3. Keep the locked window as short as possible
Every operation between the lock acquisition and the commit (or rollback) is a chance for another transaction to block on yours. Common offenders:
- Doing callouts inside a locked window — releases the lock while the callout runs but re-acquires it after, doubling exposure.
- Email send inside the lock — adds 100s of ms.
- Complex CPU work between read and write — recompute outside the lock if possible.
The clean shape:
// 1. Compute everything you can without locking
List<Computed> precomputed = compute(records);
// 2. Take the lock, do the minimum, commit
List<Account> locked = [SELECT Id, Balance__c FROM Account
WHERE Id IN :ids FOR UPDATE];
for (Account a : locked) {
a.Balance__c = precomputed.get(a.Id);
}
update locked;
4. Don’t FOR UPDATE while you also do other work
The temptation: lock the records, then do a slow REST callout, then update. The lock duration becomes “however long the third-party API took” — easily 5+ seconds. Two such transactions hitting overlapping records will deadlock.
The fix: do the callout first, then take the lock for the brief read-update-commit window.
5. Watch for trigger cascades that lock parents you didn’t intend
Inserting a Contact takes a lock on its parent Account. If your AccountTrigger then updates those accounts in after insert, you’ve effectively held the Account lock through the entire downstream pipeline — and any other transaction touching the same Account will block on you.
Frameworks like fflib’s Unit-of-Work help: they batch the DML at the end of the transaction so the locked window is one quick block of saves rather than scattered across a trigger.
6. Async deferral for heavy work
If the work is long-running, don’t do it in the user-facing transaction. Move it to:
- A Queueable chained off the trigger.
- A Platform Event with an async subscriber.
- A Batch Apex if the volume is large.
Each async transaction starts fresh — no inherited locks from the caller.
What you can do when a deadlock happens anyway
Catch it, log it, and retry. Salesforce surfaces deadlock detection as:
UNABLE_TO_LOCK_ROW: unable to obtain exclusive access to this record
The retry pattern is to back off briefly and try again — but you can’t Thread.sleep() in Apex. The async-retry pattern is to enqueue a Queueable that does the work, knowing that by the time the Queueable runs (seconds later), the contending transaction has released its locks.
try {
update accs;
} catch (DmlException e) {
if (e.getDmlType(0) == StatusCode.UNABLE_TO_LOCK_ROW) {
System.enqueueJob(new AccountUpdateRetry(accs));
} else {
throw e;
}
}
Common deadlock antipatterns
| Antipattern | Why it deadlocks |
|---|---|
| Updating same records in two opposing orders in parallel triggers | Lock ordering mismatch |
Holding FOR UPDATE across a callout | Long lock window, easy contention |
| Trigger A locks parents, trigger B inserts children — both run concurrently | A’s parent lock blocks B’s child insert which needs the parent |
| Mass DML without sorting by Id | Different orderings serialize differently |
| Roll-up summary recomputation during high-write traffic | RUS takes parent locks; concurrent child inserts pile up |
What interviewers are really looking for
The classic answer is “lock in consistent order, parents before children.” The senior signal adds: (1) sort by Id before bulk DML, (2) keep locked windows short — no callouts inside, (3) don’t FOR UPDATE unless you actually have a read-modify-write race, (4) recognise UNABLE_TO_LOCK_ROW as the runtime symptom, (5) defer heavy work to async to keep the user-facing transaction tight.
Verified against: Apex Developer Guide — Locking Statements. Last reviewed 2026-05-17.