Preventing Double-Spend in Spring Boot 3 Using Pessimistic SERIALIZABLE Locking
The Problem Nobody Warns You About
You build a fintech feature. It works perfectly in testing.
Then you go live.
A payment provider like OPay or Stripe retries its webhook — because networks are unreliable and they want to guarantee delivery. Now you have three copies of the same webhook hitting your service at the same millisecond.
Your wallet gets credited three times. Your user is now rich. Your company is not.
This is double-spend. And it's not a rare edge case — it's guaranteed to happen in production.
Why This Is Harder Than It Looks
The obvious fix is to check before you credit:
// DANGEROUS — don't do this
if (!alreadyCredited(referenceId)) {
walletService.credit(userId, amount);
}
The check and the credit are two separate operations. Between them, another thread can pass the same check. Both threads credit the wallet. You've gained nothing.
This is a check-then-act race condition, and a standard @Transactional annotation on its own will not save you.
By default, Spring uses READ_COMMITTED isolation. Threads can still read the same "not yet credited" state from each other before either one commits. You need to go further.
What Actually Happened in VerifiedCore
Building VerifiedCore — a virtual number verification platform on Spring Boot 3 + PostgreSQL — I hit this exact bug during live testing.
I fired three concurrent webhook deliveries at the same payment reference:
- The wallet correctly credited exactly once — the locking worked ✅
- But the
payment_transactionsrow ended up showingFAILED❌
Why? A slower concurrent attempt's failure-path write committed after the successful attempt's write. The last writer won, and it wrote the wrong status.
Two separate races. Two different fixes.
Fix 1: Lock the Wallet Row with Pessimistic SERIALIZABLE
The wallet-service needed to guarantee that only one credit attempt per referenceId could succeed, no matter how many concurrent requests came in.
Step 1 — Add a locking query to your repository
public interface WalletRepository extends JpaRepository<Wallet, UUID> {
// SELECT ... FOR UPDATE — acquires an exclusive row-level lock.
// Every other transaction trying to read this row will BLOCK until we commit.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT w FROM Wallet w WHERE w.userId = :userId")
Optional<Wallet> findByUserIdForUpdate(@Param("userId") UUID userId);
}
Step 2 — Run the credit inside a SERIALIZABLE transaction
@Service
public class WalletService {
@Transactional(isolation = Isolation.SERIALIZABLE)
public CreditResult credit(UUID userId, BigDecimal amount, String referenceId) {
// Safe to check here — PESSIMISTIC_WRITE locks the row before this read.
// No other transaction can slip in between the check and the credit.
if (walletTransactionRepo.existsByReferenceId(referenceId)) {
return CreditResult.ALREADY_CREDITED;
}
Wallet wallet = walletRepo
.findByUserIdForUpdate(userId) // acquires the exclusive lock
.orElseThrow();
wallet.setBalanceUsd(wallet.getBalanceUsd().add(amount));
walletRepo.save(wallet);
// Record the referenceId so future concurrent attempts bail out above.
walletTransactionRepo.save(
WalletTransaction.builder()
.walletId(wallet.getId())
.amountUsd(amount)
.referenceId(referenceId)
.build()
);
return CreditResult.SUCCESS;
}
}
What this buys you:
-
PESSIMISTIC_WRITE→ database issuesSELECT ... FOR UPDATEon the wallet row - Thread 1 acquires the lock. Threads 2 and 3 block — they don't race, they wait
- When thread 1 commits, threads 2 and 3 wake up, see
existsByReferenceId = true, and returnALREADY_CREDITED -
SERIALIZABLEisolation also blocks phantom reads — where a row inserted by another transaction is invisible to your current snapshot, letting a second thread pass the same existence check
Live test result with three concurrent webhooks:
Thread 1 → CREDIT_SUCCESS (wallet: $0.00 → $57.50)
Thread 2 → ALREADY_CREDITED (no-op)
Thread 3 → ALREADY_CREDITED (no-op)
Exactly what you want.
Fix 2: Stop the Last Writer from Corrupting the Payment Row
The wallet was safe. But the payment_transactions status row wasn't.
Thread 1 wrote status = COMPLETED. Thread 3's failure path wrote status = FAILED 200ms later — after thread 1 had already committed. Last writer wins. Wrong status persists.
The fix is optimistic locking on the payment entity. One annotation:
@Entity
@Table(name = "payment_transactions")
public class PaymentTransaction {
@Id
private UUID id;
private String status;
// Hibernate increments this on every UPDATE.
// If two threads try to update the same row version, the slower one
// throws ObjectOptimisticLockingFailureException instead of silently
// overwriting the winner's result.
@Version
private Long version;
// ... other fields
}
That's it. Hibernate now generates:
UPDATE payment_transactions
SET status = 'FAILED', version = 2
WHERE id = ? AND version = 1 -- rejected if version already moved past 1
If thread 1 committed version 1 → 2 (writing COMPLETED), thread 3's update targets version = 1 — which no longer exists. PostgreSQL rejects it. No silent overwrite.
One important rule: don't try to catch and recover inside the same @Transactional method. Once Hibernate throws an optimistic lock failure, the persistence context is in an unusable state. Let it propagate to your controller:
@PostMapping("/webhooks/payment/opay")
public ResponseEntity<?> handleWebhook(@RequestBody String payload) {
try {
paymentService.processConfirmedPayment(reference);
return ResponseEntity.ok().build();
} catch (ObjectOptimisticLockingFailureException e) {
// The row was already updated by a faster concurrent request.
// The important guarantee (no overwrite) is already met.
// Return 200 so the provider doesn't retry and compound the problem.
log.warn("Stale concurrent webhook for {}: ignoring", reference);
return ResponseEntity.ok().build();
}
}
The Two-Layer Pattern
These two mechanisms solve different races:
| Layer | Mechanism | Protects |
|---|---|---|
| Wallet balance |
PESSIMISTIC_WRITE + SERIALIZABLE
|
No duplicate credit to the ledger |
| Payment status |
@Version (optimistic lock) |
No stale FAILED overwrite on the transaction row |
- Pessimistic locking blocks threads upfront. Use it where you can't tolerate any concurrent modification — wallet balances, escrow holds, inventory.
- Optimistic locking lets threads race and rejects the loser at commit time. Use it where conflicts are rare — status fields, audit rows, idempotency metadata.
One More Gotcha: Truncate Your Error Messages
When the serialization error fired in VerifiedCore, PaymentService tried to store the raw JDBC exception text into a varchar(500) column.
The exception text was longer than 500 characters. The insert crashed. The transaction row got stuck in PENDING forever — not even FAILED.
Always truncate before storing:
private static String truncate(String s, int max) {
// Postgres throws DataException if you exceed column length.
// Truncate here so the row always persists, even on ugly errors.
return (s != null && s.length() > max)
? s.substring(0, max - 3) + "..."
: s;
}
Ship the feature. Then ship the error handling.
Pre-Ship Checklist
- [ ] Every wallet credit checks
referenceIdinside the locked transaction — not before acquiring the lock - [ ] Your
@Transactionalisolation isSERIALIZABLEon the write path, not the Spring defaultREAD_COMMITTED - [ ] Your payment entity has a
@Versioncolumn backed by a real DB column (bigint NOT NULL DEFAULT 0) - [ ] Your webhook controller catches
ObjectOptimisticLockingFailureExceptionand returns200— not500 - [ ] Your
referenceIdcolumn has aUNIQUEindex — the database is your last line of defense - [ ] Your error-reason columns are long enough, or you're truncating before save
Built and battle-tested while developing VerifiedCore — a virtual number verification platform for developers.