A single long ALTER TABLE or forgotten SELECT … FOR UPDATE can stack hundreds of waiters. Apps retry, connection pools fill, ALB targets flip unhealthy, and you get a “database is down” page that was really a lock queue. Fail-fast timeouts turn that cascade into bounded, actionable errors.
⚡ TL;DR: Set
lock_timeout(and usuallystatement_timeout) per role/session for OLTP traffic; keep migrations on a separate role with explicit longer budgets. Monitorpg_locks/pg_stat_activitywait events. Prefer skip-locked and short transactions. Pair with Distributed Deadlock Detection, Python Pool Timeouts, and SLA Error Budgets.
The cascade you are preventing
Long holder (migration / idle-in-tx)
-> waiter 1..N block on relation/row lock
-> app pool exhausts
-> upstream timeouts + retries amplify load
-> "everything 500" without a single crash
lock_timeout aborts the waiter after N ms instead of joining an unbounded queue. The holder still needs fixing—but the blast radius shrinks.
Role-scoped defaults
-- OLTP app role: fail fast on locks and statements
ALTER ROLE app_oltp SET lock_timeout = '2s';
ALTER ROLE app_oltp SET statement_timeout = '15s';
ALTER ROLE app_oltp SET idle_in_transaction_session_timeout = '30s';
-- Migration role: longer lock budget, still bounded
ALTER ROLE app_migrate SET lock_timeout = '5s';
ALTER ROLE app_migrate SET statement_timeout = '10min';
// GOOD: set on checkout for Node (pg)
pool.on("connect", (client) => {
client.query("SET lock_timeout = '2s'");
client.query("SET statement_timeout = '15s'");
});
// BAD: rely on cluster defaults of 0 (wait forever)
✅ Separate migration role with explicit budgets.
❌ One superuser role for app traffic and DDL.
Application patterns that respect timeouts
-- Prefer non-blocking claim patterns for workers
UPDATE jobs
SET status = 'running', locked_at = now()
WHERE id = (
SELECT id FROM jobs
WHERE status = 'queued'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *;
try {
await db.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [amt, id]);
} catch (e: any) {
if (e.code === "55P03") { // lock_not_available
metrics.increment("pg.lock_timeout");
throw new TransientError("lock_timeout"); // retry with jitter, or shed
}
throw e;
}
Map 55P03 and 57014 (query_canceled) to retry/shed policies that match your SLA error budgets. Do not blindly retry forever—that recreates the queue.
Observability during incidents
SELECT pid, usename, state, wait_event_type, wait_event,
now() - query_start AS runtime, left(query, 120)
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY runtime DESC;
Alert when lock waiters exceed N for M minutes, or when lock_timeout error rate spikes. Tie to Distributed Deadlock Detection when Redis fencing and Postgres rows interact.
Closing checklist
- [ ] OLTP roles have non-zero
lock_timeoutandstatement_timeout - [ ] Migrations use a separate role with documented budgets
- [ ] App maps
55P03to transient/shed paths, not infinite retries - [ ] Workers prefer
SKIP LOCKEDover blocking claims - [ ] Idle-in-transaction timeout enabled
- [ ] Alerts on lock waiter depth and timeout error rate
Related reading
- Distributed Deadlock Detection: Cycles Across Postgres Rows and Redis Locks
- Python Pool Timeouts: Connection Checkout Bugs That Look Like 500s
- SLA Error Budgets: Drive Automated Load Shedding Before Hard Outages
- Exactly-Once Illusions: Design At-Least-Once Plus Truly Idempotent Handlers
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
