Why I didn't reach for Redis: payment idempotency with PostgreSQL row locks
Classly processes payments through two gateways. Here's how I used SELECT FOR UPDATE SKIP LOCKED and advisory locks to get idempotency without adding a lock service.
When I was building the payment backend for Classly, I hit a problem I’d read about but never actually dealt with: duplicate charges.
A user clicks “Enroll.” The request goes out. The network hiccups. The client retries. Both requests reach the server at the same time — one from the original attempt, one from the retry. If I’m not careful, both create a payment order. The user gets charged twice and I get a support ticket I can’t ignore.
The obvious solution everyone reaches for is Redis. Grab a distributed lock, set a TTL, release it when done. Simple. But Classly already has PostgreSQL as its primary store. Adding Redis just for locking means another process to run, another failure mode, and another thing that can go out of sync with the database. I decided to see how far I could get with just Postgres.
The problem in concrete terms
Classly integrates with two payment gateways — Razorpay and a backup. Each payment flow looks roughly like this:
- Client sends
POST /payments/initiatewith a course ID and user ID - Server creates a payment record, calls the gateway, gets back an
order_id - Client completes payment on the gateway’s hosted page
- Gateway fires a webhook to
POST /payments/webhook - Server verifies the webhook signature, marks the enrollment as active
Steps 2 and 4 are the dangerous ones. If step 2 runs twice for the same user+course, you get two orders on the gateway side. If step 4 runs twice (webhooks will retry), you might double-enroll or corrupt state.
Idempotency keys at the database level
The first thing I did was add an idempotency_key column to the payment_orders table
with a UNIQUE constraint:
CREATE TABLE payment_orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
course_id BIGINT NOT NULL REFERENCES courses(id),
idempotency_key TEXT NOT NULL,
gateway TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
gateway_order JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT payment_orders_idempotency_key_unique UNIQUE (idempotency_key)
);
The idempotency key is generated on the client side (or derived deterministically from
user_id + course_id + timestamp-bucket) and sent with every request.
If two identical requests arrive simultaneously, one of them hits the INSERT first.
The other gets a 23505 unique-violation error. That’s a retry-safe failure — I catch
it, look up the existing row, and return its current state to the client. No duplicate
order created.
But there’s a race condition here. Between the INSERT and the gateway call, another
request with the same idempotency key might slip through (if I’m checking existence
before inserting rather than relying on the constraint). The fix: always try the INSERT
first, catch the constraint violation, then read. Never check-then-insert.
SELECT FOR UPDATE on the webhook path
Webhooks are where it gets messier. Razorpay retries webhooks for up to 24 hours if it doesn’t receive a 200 response. If my server is slow — or if I accidentally return a non-200 — I’ll receive the same webhook multiple times.
Marking an enrollment as active is not idempotent by default. Running it twice might double-credit something, send duplicate emails, or cause a constraint violation downstream. I needed to make the entire webhook handler idempotent.
Here’s the pattern I use:
async function handleWebhook(payload, signature) {
await verifySignature(payload, signature) // throws if invalid
const { order_id, payment_id, status } = parsePayload(payload)
await db.transaction(async (trx) => {
// Lock the row for this order — any concurrent webhook for the same
// order_id will block here until this transaction commits or rolls back.
const order = await trx.raw(`
SELECT * FROM payment_orders
WHERE gateway_order->>'id' = ?
FOR UPDATE
`, [order_id])
if (!order.rows.length) return // unknown order, ignore
const row = order.rows[0]
// Already processed — return early without doing anything.
if (row.status === 'completed') return
// Mark complete and activate enrollment atomically.
await trx('payment_orders')
.where({ id: row.id })
.update({ status: 'completed', gateway_payment_id: payment_id })
await trx('enrollments').insert({
user_id: row.user_id,
course_id: row.course_id,
activated_at: new Date(),
}).onConflict(['user_id', 'course_id']).ignore()
})
}
The SELECT ... FOR UPDATE is the key line. PostgreSQL acquires a row-level lock on
that payment order for the duration of the transaction. If two webhook deliveries arrive
simultaneously for the same order_id, one of them gets the lock, processes, and
commits. The other waits, then sees status === 'completed', and returns early.
No double-enrollment.
The .onConflict().ignore() on the enrollments insert is a second safety net — if the
row already exists, we skip silently.
SKIP LOCKED for background jobs
There’s a third case: deferred enrollment checks. Some courses have a delayed activation
(e.g., cohort-based start dates). I have a background job that polls for
status = 'completed' enrollments that haven’t been activated yet.
If I run multiple workers, they’ll all try to grab the same rows. I don’t want two
workers activating the same enrollment. SKIP LOCKED solves this cleanly:
SELECT id, user_id, course_id
FROM enrollments
WHERE activated_at IS NULL
AND scheduled_activation <= NOW()
FOR UPDATE SKIP LOCKED
LIMIT 10;
Each worker grabs up to 10 rows that aren’t already locked by another worker. Rows being processed by worker A are skipped by worker B entirely — no waiting, no deadlocks. When worker A commits, those rows either get marked done or get picked up by the next poll cycle.
This pattern gives you a job queue inside Postgres with zero extra infrastructure. It’s not as featureful as BullMQ or a proper queue, but for a use case this contained, it’s hard to argue for the complexity.
What I’d do differently
The idempotency key derivation is the thing I’d revisit. I’m currently generating it
on the server from user_id + course_id + floor(timestamp / 5min) — a time-bucketing
approach. This means retries within a 5-minute window are safe, but a retry across
the boundary might create a new order. A true client-generated key (UUID the client
mints once and resends forever) would be cleaner. I’ll change this in the next
refactor.
The advisory lock is another option I considered — pg_try_advisory_xact_lock(user_id)
— which would prevent a single user from initiating two payments simultaneously without
needing to have a row in the table yet. Worth exploring if the initiation race becomes
a real problem at higher concurrency.
For now: no Redis, no external lock service. Just Postgres, used carefully.