Back to Articles

Spring Boot · Payments

Idempotency: How I Prevent Duplicate Requests

2026-10-158 min read
IdempotencyRedis

A mobile client submits a payment. The network drops before the response arrives. The client, following completely reasonable retry logic, submits the same payment again. Without idempotency, the customer is charged twice for one purchase, and no amount of client-side retry logic can fix a server that treats the retry as a brand new request.

The problem

A network failure between a client and server is ambiguous by nature: the request might have failed before reaching the server, or it might have succeeded and only the response got lost. The client cannot tell the difference, so a correct client always retries. A correct server has to make that retry safe.

The naive solution

PaymentController.java
1@PostMapping("/payments")
2public PaymentResponse charge(@RequestBody PaymentRequest request) {
3 return paymentGateway.charge(request.amount(), request.cardToken());
4}

Every call to this endpoint charges the card again, with no concept of "have I already handled this exact request." Two identical POST requests, whether from a genuine retry or a double-tap on a checkout button, produce two charges.

Why it breaks

  • Retries are not optional at the client level; TCP resets, mobile network handoffs, and load balancer timeouts all trigger them whether the client code wants to retry or not.
  • A refund after the fact is a worse customer experience and a harder support conversation than preventing the double charge in the first place.
  • The same problem applies beyond payments: duplicate order creation, duplicate emails, duplicate inventory reservations, any side effect that is not naturally safe to repeat.

The production solution

The client generates a unique Idempotency-Key per logical operation (not per HTTP attempt) and sends it on every retry. The server stores the outcome of the first request under that key and returns the same outcome for every subsequent request with the same key, without re-executing the charge:

idempotency_keys table
1CREATE TABLE idempotency_keys (
2 idempotency_key VARCHAR(64) PRIMARY KEY,
3 response_body JSONB NOT NULL,
4 status_code INT NOT NULL,
5 created_at TIMESTAMPTZ NOT NULL DEFAULT now()
6);
PaymentController.java
1@PostMapping("/payments")
2public ResponseEntity<PaymentResponse> charge(
3 @RequestHeader("Idempotency-Key") String idempotencyKey,
4 @RequestBody PaymentRequest request) {
5
6 Optional<StoredResponse> existing = idempotencyStore.find(idempotencyKey);
7 if (existing.isPresent()) {
8 return ResponseEntity.status(existing.get().statusCode())
9 .body(existing.get().body());
10 }
11
12 try {
13 PaymentResponse response = paymentGateway.charge(
14 request.amount(), request.cardToken());
15 idempotencyStore.save(idempotencyKey, HttpStatus.OK.value(), response);
16 return ResponseEntity.ok(response);
17 } catch (DataIntegrityViolationException raceCondition) {
18 // Another request with the same key committed first
19 return idempotencyStore.find(idempotencyKey)
20 .map(r -> ResponseEntity.status(r.statusCode()).body(r.body()))
21 .orElseThrow(() -> raceCondition);
22 }
23}

The primary key constraint on idempotency_key is doing real work here, not just indexing. Two concurrent requests with the same key racing each other will have one succeed and one hit a unique constraint violation, which is the signal to look up and return the winner's response instead of erroring out.

Making it reusable

This pattern generalizes to any endpoint with a side effect that must not repeat, not just payments. A Spring HandlerInterceptor or method-level annotation can wrap the lookup-or-execute logic once, so individual controllers only declare "this endpoint requires idempotency" rather than reimplementing the store lookup each time.

Testing it

The test that actually matters here is concurrency, not the happy path: fire two requests with the same idempotency key at the same time and assert that exactly one charge occurred and both responses match. A sequential test of "call twice, assert once charged" will pass even with a race condition that a load test would expose immediately.

What I learned

Idempotency keys need to expire eventually (a TTL job or a scheduled cleanup on created_at), otherwise the table grows forever and a customer who wants to make the exact same purchase a year later gets served last year's cached response by mistake if the client ever reuses a key. Pick a TTL that comfortably outlives your client's retry window and nothing more.