PostgreSQL Advisory Locks in 2026: Distributed Locking, Cron Jobs, and Race Condition Prevention
Use PostgreSQL advisory locks for distributed locking: pg_try_advisory_lock, session vs transaction locks, preventing duplicate cron jobs, leader election, and TypeScript helpers.
PostgreSQL Advisory Locks in 2026: Distributed Locking, Cron Jobs, and Race Condition Prevention
Advisory locks are PostgreSQL's built-in mechanism for application-level coordination. Unlike row locks (SELECT FOR UPDATE) which lock database rows, advisory locks lock arbitrary integers you defineβperfect for "only one server should run this cron job" or "only one request should process this payment" scenarios.
The best part: if you're already using PostgreSQL, you get distributed locking for free. No Redis Redlock, no ZooKeeper, no additional infrastructure.
Advisory Lock Fundamentals
PostgreSQL provides two scopes and two modes:
Scope:
- Session-level: lock persists for the entire database session; must be explicitly released or connection close
- Transaction-level: lock automatically released when the transaction commits or rolls back
Mode:
- Exclusive: only one holder at a time (most common)
- Shared: multiple shared holders, but blocks exclusive holders
Key functions:
-- Session locks (must release manually)
SELECT pg_advisory_lock(key); -- Wait until acquired
SELECT pg_try_advisory_lock(key); -- Return true/false immediately
SELECT pg_advisory_unlock(key); -- Release
-- Transaction locks (auto-released on commit/rollback)
SELECT pg_advisory_xact_lock(key); -- Wait until acquired
SELECT pg_try_advisory_xact_lock(key); -- Return true/false immediately
-- No unlock needed β automatic on transaction end
-- Shared locks
SELECT pg_advisory_lock_shared(key);
SELECT pg_try_advisory_lock_shared(key);
Keys are 64-bit integers. Use consistent hashing of strings to generate them:
// lib/db/advisory-lock.ts
import { createHash } from "crypto";
/**
* Convert a string key to a 64-bit integer for advisory locks.
* Uses the first 8 bytes of SHA-256 β collision probability is negligible.
*/
export function lockKey(name: string): bigint {
const hash = createHash("sha256").update(name).digest();
// Read first 8 bytes as a signed 64-bit integer
return hash.readBigInt64BE(0);
}
// Examples:
// lockKey("billing-cron") β -3456789012345678901n
// lockKey("payment-xyz") β 1234567890123456789n
Pattern 1: Distributed Cron Lock
Prevent duplicate cron job execution across multiple server instances:
// lib/cron/advisory-lock.ts
import { Pool, PoolClient } from "pg";
import { lockKey } from "@/lib/db/advisory-lock";
interface CronLockOptions {
jobName: string;
fn: () => Promise<void>;
pool: Pool;
timeoutMs?: number; // How long to wait for lock (0 = don't wait)
}
/**
* Run fn() exclusively across all server instances.
* If another instance is already running this job, skip.
*/
export async function withCronLock({
jobName,
fn,
pool,
timeoutMs = 0,
}: CronLockOptions): Promise<{ ran: boolean; durationMs?: number }> {
const key = lockKey(`cron:${jobName}`);
const client = await pool.connect();
try {
// Try to acquire session lock (non-blocking)
const result = await client.query<{ acquired: boolean }>(
`SELECT pg_try_advisory_lock($1) as acquired`,
[key]
);
const acquired = result.rows[0].acquired;
if (!acquired) {
console.log(`Cron job ${jobName}: skipping β already running on another instance`);
return { ran: false };
}
console.log(`Cron job ${jobName}: acquired lock, running...`);
const start = Date.now();
try {
await fn();
const durationMs = Date.now() - start;
console.log(`Cron job ${jobName}: completed in ${durationMs}ms`);
return { ran: true, durationMs };
} finally {
// Always release the lock, even if fn() throws
await client.query(`SELECT pg_advisory_unlock($1)`, [key]);
}
} finally {
client.release();
}
}
Usage in a cron handler:
// app/api/cron/send-digest-emails/route.ts
import { NextRequest, NextResponse } from "next/server";
import { withCronLock } from "@/lib/cron/advisory-lock";
import { pool } from "@/lib/db";
export async function GET(req: NextRequest) {
const authHeader = req.headers.get("Authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { ran, durationMs } = await withCronLock({
jobName: "send-digest-emails",
pool,
fn: async () => {
const users = await getUsersForDigest();
await sendDigestEmails(users);
},
});
return NextResponse.json({
ran,
durationMs,
timestamp: new Date().toISOString(),
});
}
π Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different β senior engineers only, direct Slack access, and a 5.0β Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript β production-grade stack
- Fixed-price contracts β no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Pattern 2: Transaction-Level Resource Lock
For within-request locking (e.g., "only process this payment once"):
// lib/payments/process.ts
import { db } from "@/lib/db";
import { lockKey } from "@/lib/db/advisory-lock";
/**
* Process a payment exactly once β even if two requests arrive simultaneously.
* Uses transaction-level advisory lock (auto-released on commit/rollback).
*/
export async function processPaymentOnce(paymentId: string) {
const key = lockKey(`payment:${paymentId}`);
return db.$transaction(async (tx) => {
// Try to acquire transaction lock β non-blocking
const result = await tx.$queryRaw<[{ acquired: boolean }]>`
SELECT pg_try_advisory_xact_lock(${key}::bigint) as acquired
`;
if (!result[0].acquired) {
// Another transaction is processing this payment right now
throw new Error("PAYMENT_ALREADY_PROCESSING");
}
// Lock acquired β we're the only one processing this payment
const payment = await tx.payment.findUnique({ where: { id: paymentId } });
if (!payment) throw new Error("Payment not found");
if (payment.status !== "pending") {
// Already processed (by a previous request)
return payment;
}
// Process the payment
const stripeResult = await chargeStripe(payment);
const updated = await tx.payment.update({
where: { id: paymentId },
data: {
status: "succeeded",
stripeChargeId: stripeResult.id,
processedAt: new Date(),
},
});
// Lock auto-released when transaction commits
return updated;
});
}
Pattern 3: Leader Election
For background workers where exactly one instance should be "leader":
// lib/workers/leader-election.ts
import { Pool } from "pg";
import { lockKey } from "@/lib/db/advisory-lock";
export class LeaderElection {
private client: any = null;
private isLeader = false;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
constructor(
private readonly pool: Pool,
private readonly role: string,
private readonly onBecomeLeader: () => void,
private readonly onLoseLeadership: () => void
) {}
async start() {
await this.tryBecomeLeader();
// Retry leadership every 30 seconds
this.heartbeatInterval = setInterval(() => {
this.tryBecomeLeader().catch(console.error);
}, 30_000);
// Release on shutdown
process.on("SIGTERM", () => this.stop());
process.on("SIGINT", () => this.stop());
}
private async tryBecomeLeader() {
if (this.isLeader) {
// Already leader β keep the connection alive with a ping
try {
await this.client.query("SELECT 1");
} catch {
// Connection died β lost leadership
this.isLeader = false;
this.client = null;
this.onLoseLeadership();
}
return;
}
const key = lockKey(`leader:${this.role}`);
const client = await this.pool.connect();
try {
const result = await client.query<{ acquired: boolean }>(
`SELECT pg_try_advisory_lock($1) as acquired`,
[key]
);
if (result.rows[0].acquired) {
this.client = client;
this.isLeader = true;
console.log(`Became leader for role: ${this.role}`);
this.onBecomeLeader();
// Don't release client β hold the lock
} else {
client.release(); // Not the leader β release immediately
}
} catch (err) {
client.release();
throw err;
}
}
async stop() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
if (this.client) {
const key = lockKey(`leader:${this.role}`);
try {
await this.client.query(`SELECT pg_advisory_unlock($1)`, [key]);
} finally {
this.client.release();
this.client = null;
this.isLeader = false;
}
}
}
}
// Usage
const election = new LeaderElection(
pool,
"report-generator",
() => {
console.log("Starting report generation worker...");
startReportWorker();
},
() => {
console.log("Lost leadership β stopping report worker");
stopReportWorker();
}
);
await election.start();
π Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4β8 weeks, full platforms in 3β5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Pattern 4: Per-Tenant Operation Serialization
Prevent concurrent operations for the same tenant from conflicting:
// lib/billing/subscription-manager.ts
/**
* Serialize subscription changes per team.
* Prevents race conditions when two requests try to upgrade/downgrade simultaneously.
*/
export async function changeSubscription(
teamId: string,
newPlan: string
) {
const key = lockKey(`subscription:${teamId}`);
return db.$transaction(async (tx) => {
// Acquire lock for this specific team
await tx.$executeRaw`
SELECT pg_advisory_xact_lock(${key}::bigint)
`;
// This WAITS (blocking) if another transaction holds the lock
// Transaction lock is released automatically on commit/rollback
const current = await tx.subscription.findFirst({
where: { teamId, status: "active" },
});
if (!current) throw new Error("No active subscription found");
if (current.plan === newPlan) return current; // Already on this plan
// Safe to change β no concurrent modification possible
const updated = await tx.subscription.update({
where: { id: current.id },
data: { plan: newPlan, updatedAt: new Date() },
});
await tx.subscriptionHistory.create({
data: {
teamId,
fromPlan: current.plan,
toPlan: newPlan,
changedAt: new Date(),
},
});
return updated;
});
}
Monitoring Lock Contention
-- See currently held advisory locks
SELECT
pid,
usename,
application_name,
classid,
objid,
locktype,
granted,
now() - query_start AS held_duration
FROM pg_locks
JOIN pg_stat_activity USING (pid)
WHERE locktype = 'advisory'
ORDER BY held_duration DESC;
-- Find long-running advisory lock holders
SELECT
pid,
usename,
left(query, 80) AS query_snippet,
now() - query_start AS query_duration,
classid,
objid
FROM pg_locks
JOIN pg_stat_activity USING (pid)
WHERE locktype = 'advisory'
AND now() - query_start > INTERVAL '1 minute'
ORDER BY query_duration DESC;
TypeScript Helper Library
// lib/db/advisory-lock.ts (complete)
import { Pool, PoolClient } from "pg";
import { createHash } from "crypto";
export function lockKey(name: string): bigint {
const hash = createHash("sha256").update(name).digest();
return hash.readBigInt64BE(0);
}
type LockFn = (
client: PoolClient,
key: bigint
) => Promise<boolean>;
/**
* Generic advisory lock helper for session-level locks.
*/
export async function withAdvisoryLock<T>(
pool: Pool,
name: string,
fn: () => Promise<T>,
options: { blocking?: boolean } = {}
): Promise<{ result: T | null; acquired: boolean }> {
const key = lockKey(name);
const client = await pool.connect();
try {
const lockFn = options.blocking
? `SELECT pg_advisory_lock($1)`
: `SELECT pg_try_advisory_lock($1) as acquired`;
const lockResult = await client.query(lockFn, [key]);
const acquired = options.blocking
? true
: lockResult.rows[0].acquired as boolean;
if (!acquired) {
return { result: null, acquired: false };
}
try {
const result = await fn();
return { result, acquired: true };
} finally {
await client.query(`SELECT pg_advisory_unlock($1)`, [key]);
}
} finally {
client.release();
}
}
/**
* Transaction-level advisory lock (auto-released on commit/rollback).
* Use inside a Prisma or pg transaction.
*/
export async function acquireTransactionLock(
tx: { $executeRaw: Function },
name: string,
blocking: boolean = false
): Promise<boolean> {
const key = lockKey(name);
if (blocking) {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${key}::bigint)`;
return true;
}
const result = await tx.$queryRaw<[{ acquired: boolean }]>`
SELECT pg_try_advisory_xact_lock(${key}::bigint) as acquired
`;
return result[0].acquired;
}
When Not to Use Advisory Locks
| Scenario | Better Tool | Why |
|---|---|---|
| High-contention resources (100+ req/s) | Redis Redlock or queue | Advisory locks have overhead at extreme scale |
| Locks across multiple databases | Redis or Zookeeper | Advisory locks are per-database |
| Fine-grained row locking | SELECT FOR UPDATE | Row locks are purpose-built for this |
| Cross-service coordination | Message queue | Advisory locks require shared DB access |
| Very short critical sections (<1ms) | Atomic operations | Lock overhead may exceed critical section |
For most SaaS products (< 1M req/day), advisory locks handle coordination perfectly. Switch to Redis when you see lock wait times consistently above 100ms.
Cost and Timeline
| Task | Timeline | Cost (USD) |
|---|---|---|
| Cron lock pattern setup | 0.5 day | $400 |
| Transaction lock for idempotency | 0.5β1 day | $400β$800 |
| Leader election implementation | 1β2 days | $800β$1,600 |
| Full lock library + monitoring | 2β3 days | $1,600β$2,500 |
Advisory locks cost $0 (no additional infrastructure). The engineering cost is purely implementation time.
See Also
- PostgreSQL Row-Level Security β Row-level data isolation
- PostgreSQL Partitioning β Data partitioning for large tables
- Redis Advanced Patterns β Redlock for cross-database distributed locking
- AWS SQS Worker Pattern β Queue-based serialization alternative
Working With Viprasol
We build concurrency-safe backend systems for SaaS productsβfrom distributed lock patterns through full event sourcing architectures. Our engineers have implemented advisory lock systems for financial applications where double-processing would have serious consequences.
What we deliver:
- Advisory lock helpers integrated into your existing PostgreSQL setup
- Cron job deduplication across multi-instance deployments
- Idempotent payment processing with lock-based guarantees
- Lock contention monitoring and alerting
- Architecture review for race condition vulnerabilities
Explore our web development services or contact us to discuss your concurrency requirements.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms β we build it all with Next.js and React.
Free consultation β’ No commitment β’ Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse β₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β from architecture through deployment.