What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

Two deliveries of the same Stripe event can reach two workers at the same moment, and both can pass a “not processed yet” check before either writes anything. The fix has two parts. First, both handlers take a transaction-level PostgreSQL advisory lock on the same key for that payment, so the second handler waits until the first one commits or rolls back. Second, the duplicate check and the state change run inside that same transaction, so the event record and the payment update either both happen or neither does. The lock only protects code that asks for it, which means every path that changes the payment must use the same key and the same protocol.

What the lock does and does not do

n

PostgreSQL’s documentation describes advisory locks as a way to give database locks meanings that only the application defines:

n

“PostgreSQL provides a means for creating locks that have application-defined meanings.”

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

n

Source: PostgreSQL Global Development Group, PostgreSQL documentation, chapter “Explicit Locking,” section “Advisory Locks.”

n

The database does not know that key 1042 means payment 1042. It records that a key is held and makes other sessions that request the same key wait. Nothing forces code to ask. Two consequences follow:

n

    n

  • The lock is cooperative. A refund job, an admin script, or a second endpoint that updates the same payment without calling the lock will not wait for anyone.
  • n

  • Identity is the application’s responsibility. Every writer must derive the same key from the same logical payment.
  • n

n

The function pg_advisory_xact_lock is documented in the PostgreSQL “Advisory Lock Functions” reference as: “Obtains an exclusive transaction-level advisory lock, waiting if necessary.” The same documentation explains the lifecycle of transaction-level locks:

n

“Transaction-level lock requests, on the other hand, behave more like regular lock requests: they are automatically released at the end of the transaction, and there is no explicit unlock operation.”

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

n

Source: PostgreSQL Global Development Group, PostgreSQL documentation, “Explicit Locking,” section “Advisory Locks.”

n

Transaction-level versus session-level locks

n

PostgreSQL also offers session-level advisory locks (pg_advisory_lock). They behave differently, and the difference matters for payment code.

n

n

n

n

n

n

n

n

n

n

n

Aspect Transaction-level (pg_advisory_xact_lock) Session-level (pg_advisory_lock)
When it is released Automatically at COMMIT or ROLLBACK; no unlock call exists Only by pg_advisory_unlock or when the session ends
Effect of rollback Released along with the transaction Not rolled back with a transaction; stays held
Connection-pool risk Low, because the lock ends with the transaction A connection returned to the pool with the lock still held can block the next user of that connection’s work
Fit for a webhook handler Preferred when all protected work fits in one transaction Only when protected work spans several transactions and your code unlocks on every path, including errors

n

For a webhook handler, the transaction-level form is almost always the right choice, because the whole protected unit of work can live in one transaction.

n

Choose a key that identifies the payment

n

The key must come from something stable about the thing you are serializing. Use your internal payment primary key rather than a value that might be created, renamed, or reformatted later. Provider identifiers such as a payment intent ID are strings, so store them in a column and look up the internal ID before taking the lock.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

n

If a webhook can arrive before your code has created the payment row, insert the row first with a statement that tolerates an existing row (for example, INSERT ... ON CONFLICT DO NOTHING), then take the lock.

n

PostgreSQL accepts two forms of key:

n

n

n

n

n

n

n

n

n

Form Key shape Namespacing Limit
Single key, pg_advisory_xact_lock(bigint) One 64-bit value None built in; all keys in the database share one space Any bigint value
Pair, pg_advisory_xact_lock(int, int) Two 32-bit values, for example a constant for “payments” and the payment ID The first value can act as a namespace, which keeps your keys apart from other advisory-lock users Each part must fit a 32-bit signed integer, so IDs above about 2.1 billion cannot use this form

n

If you hash a string into a key, do not assume the hash is collision-free. Two payments that collide will wait for each other. That costs throughput, but it does not by itself corrupt data, provided the state change is conditional on the row’s current status, as shown in the next section. Document the mapping next to the lock function so every team member derives keys the same way.

n

The handler transaction, step by step

n

    n

  1. Verify the webhook signature and parse the event before opening a transaction. This is cheap, takes no locks, and rejects bad input early.
  2. n

  3. Open one transaction on one database connection. Every statement below runs inside it.
  4. n

  5. Take the lock with SELECT pg_advisory_xact_lock($1::bigint), where $1 is the internal payment ID.
  6. n

  7. Record the event ID by inserting into processed_webhook_events with ON CONFLICT (event_id) DO NOTHING RETURNING event_id. If no row comes back, this event has already been applied. Commit and return success without further changes.
  8. n

  9. Apply the state change conditionally, for example UPDATE payments SET status = 'succeeded' WHERE id = $1 AND status = 'pending'. Check the affected-row count. Zero rows means the payment has already moved past that state, and your code should record and return that outcome deliberately.
  10. n

  11. Commit. The event record and the state change succeed or fail together, so the database never marks an event as processed without applying it.
  12. n

n

CREATE TABLE processed_webhook_events (n  event_id     text PRIMARY KEY,n  payment_id   bigint NOT NULL REFERENCES payments (id),n  processed_at timestamptz NOT NULL DEFAULT now()n);nnBEGIN;nnSELECT pg_advisory_xact_lock($1::bigint);   -- $1 = payments.idnnINSERT INTO processed_webhook_events (event_id, payment_id)nVALUES ($2, $1)nON CONFLICT (event_id) DO NOTHINGnRETURNING event_id;n-- No row returned: duplicate. Run COMMIT and return success.nnUPDATE paymentsn   SET status = 'succeeded', updated_at = now()n WHERE id = $1n   AND status = 'pending';n-- Zero rows updated: the payment is no longer pending. Handle explicitly.nnCOMMIT;

n

The primary key on event_id is a design choice recommended here, not a rule that the PostgreSQL documentation prescribes. It is a backstop. If some code path skips the lock and inserts the same event ID concurrently, the constraint rejects the second insert, so the advisory lock is not the only thing standing between you and a duplicate record.

n

Why deduplicating events is not enough

n

Recording event IDs handles exact replays of the same event. It does not resolve two different events for the same payment, such as a success event and a later refund or dispute event arriving in the reverse order. The lock serializes those handlers, and the conditional update decides whether each transition is still valid. What your code does with a rejected transition is business logic. Write that decision down and test it rather than leaving it to whatever path happens to run.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

n

Keep the locked section short

n

The lock is held from step 3 until COMMIT. Anything slow inside that window makes every other handler for the same payment wait.

n

    n

  • Do not call the Stripe API, send email, or make any other HTTP request between the lock and the commit.
  • n

  • If an external side effect must happen, such as a receipt or a fulfilment call, write a durable record of it in the same transaction. Let a separate worker perform the call after commit, using its own idempotency key. This is a common engineering pattern, not a feature of PostgreSQL or Stripe.
  • n

n

Waiting, deadlocks, and the try-lock variant

n

The blocking form, pg_advisory_xact_lock, waits for the holder to finish. The try form, pg_try_advisory_xact_lock, returns immediately.

n

n

n

n

n

n

n

n

n

Call When the key is already held Return value Use when
pg_advisory_xact_lock(key) Waits until the holding transaction ends void A second handler should queue behind the first and then read the committed state. This is the usual choice for webhook handlers.
pg_try_advisory_xact_lock(key) Returns at once without waiting boolean; false when the lock is held You need a fast answer and can let the event be handled again later.

n

If you choose the try form, define the response before shipping it. Returning a non-success status may cause the sender to deliver the event again, but this article does not establish Stripe’s retry schedule. Check Stripe’s webhook documentation before depending on that behavior.

n

Handling deadlocks

n

PostgreSQL detects deadlocks and aborts one of the transactions involved. Suppose a handler locks payment 7 and then payment 9, while a batch job locks 9 and then 7. The database breaks the cycle by aborting one side. Prevent most of these cases by acquiring multiple keys in ascending order everywhere in your code.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

n

When an abort does happen, the error carries SQLSTATE 40P01 (deadlock_detected). Retry the entire transaction from the beginning, using a small fixed number of attempts with a short randomized delay. Three attempts is a reasonable starting point. Do not retry indefinitely, because an unbounded loop turns a transient conflict into a stuck worker.

n

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Watching lock contention

n

When handlers slow down, inspect the lock table:

n

SELECT pid, classid, objid, objsubid, mode, grantednFROM pg_locksnWHERE locktype = 'advisory';

n

For a single bigint key, PostgreSQL reports the high 32 bits in classid and the low 32 bits in objid, with objsubid set to 1. For a two-part key, classid and objid hold the two parts and objsubid is 2. Rows with granted = false are waiting sessions. Because pg_locks shows numeric keys rather than payments, log the payment ID and the key together so you can translate what you see.

n

Common symptoms and where to look:

n

    n

  • Many waiting sessions on the same key and growing handler latency. A transaction is probably holding the lock too long. Look for network calls inside the transaction.
  • n

  • Deadlock errors with SQLSTATE 40P01 in logs. Compare the key order in every code path that takes more than one lock.
  • n

  • Duplicate state changes despite the lock. Find the write path that updates payments without calling the lock function, then route it through the same protocol.
  • n

  • Waits that do not end. A session-level lock may have been taken and never released. Check pg_stat_activity for an idle session whose PID holds a lock in pg_locks.
  • n

n

What this pattern does not guarantee

n

The pattern stops concurrent handlers for one payment from interleaving their checks and writes, and it turns a replay of an already-recorded event into a no-op. It does not make external side effects exactly-once, and it does not establish that Stripe delivers each event once, in order, or at all.

n

Stripe’s own idempotency features are separate from webhook handling:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

n

    n

  • Idempotency keys apply to API requests you send to Stripe, such as a refund you create. They are not a deduplication mechanism for incoming webhooks. Stripe’s API reference states that an idempotency key can be removed once it is at least 24 hours old.
  • n

  • Stripe’s Events API returns events from the last 30 days, according to its API reference. Use it to reconcile events your handler missed, but only within that window.
  • n

  • This article does not establish Stripe’s retry schedule or delivery ordering. Design the handler to accept repeated and out-of-order events in either case.
  • n

n

Limits such as the 24-hour and 30-day figures can change, so confirm them in Stripe’s current API reference before relying on them.

n

Before you ship

n

    n

  • Search the codebase and migrations for every statement that writes to payments or processed_webhook_events, and confirm each one takes the lock first.
  • n

  • Write a test that starts two handlers for the same event ID and checks that one applies the change while the other becomes a no-op.
  • n

  • Write a test that runs two multi-key transactions in opposite order, confirms one is aborted, and confirms the retry path completes.
  • n

  • Log the lock key and the time spent waiting for each advisory lock, so contention is visible before it becomes an incident.
  • n

  • Keep the key-derivation function in one place, with a comment that names the namespace constant and the table it protects.
  • n

“

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.