A user clicks “Pay”, the network drops, the client retries… and your backend creates two payments.
This isn’t a “frontend bug”. Retries happen everywhere:
- mobile networks
- API gateways with automatic retry
- load balancers / timeouts
- users smashing the refresh button
If your POST endpoint creates something with side effects (charge card, create order, send email), you want idempotency.
This post shows a pragmatic, production-friendly implementation of Idempotency-Key in Spring Boot.
What idempotency means
With an idempotency key, the client sends a header:
Idempotency-Key: 7f3b2c1c-4f4f-4c8d-9b7e-0f18d4d0b2aa
Your API guarantees:
- First request with that key executes normally
- Subsequent requests with the same key return the same result
- If another request with the same key is in progress, you respond predictably (409/202) instead of doing the work twice
The key idea: store the outcome of the first request and replay it.
Contract design
A minimal contract that works well:
- Clients must send
Idempotency-KeyforPOSTendpoints with side effects - The key is unique per endpoint + per user (avoid collisions)
- You store successful outcomes for a short TTL (e.g. 24h)
- If a request is already in progress:
- return
409 Conflictwith a “try again” message, or - return
202 Accepted+Retry-Afterand let clients poll
- return
This post uses 409 (simple and explicit).
Storage model
You need somewhere to store results. A simple table works:
CREATE TABLE idempotency_record (
idempotency_key VARCHAR(200) PRIMARY KEY,
status VARCHAR(20) NOT NULL, -- IN_PROGRESS / COMPLETED
response_status INT NULL,
content_type VARCHAR(100) NULL,
response_body TEXT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL
);
Two important constraints:
-
idempotency_keymust be unique -
expires_atlets you clean up old keys
Implementation approach
There are several ways to wire this into Spring. The cleanest (for most teams):
- Mark endpoints with an annotation
@Idempotent - Use a Spring AOP aspect (
@Around) that: 1) checks existing record 2) locks/creates an “in progress” record 3) executes the controller method 4) stores the response if successful 5) returns either stored response or the new response
This avoids filters that must buffer request/response bodies and keeps business code readable.
Step 1) Annotation
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/** TTL in seconds for stored outcomes */
long ttlSeconds() default 24 * 60 * 60; // 24h
}
Step 2) Entity + Repository
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "idempotency_record")
public class IdempotencyRecord {
@Id
@Column(name = "idempotency_key", length = 200)
private String idempotencyKey;
@Column(nullable = false, length = 20)
private String status; // IN_PROGRESS / COMPLETED
@Column(name = "response_status")
private Integer responseStatus;
@Column(name = "content_type", length = 100)
private String contentType;
@Lob
@Column(name = "response_body")
private String responseBody;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
// getters/setters omitted for brevity
public static IdempotencyRecord inProgress(String key, Instant now, Instant expiresAt) {
IdempotencyRecord r = new IdempotencyRecord();
r.idempotencyKey = key;
r.status = "IN_PROGRESS";
r.createdAt = now;
r.updatedAt = now;
r.expiresAt = expiresAt;
return r;
}
public void complete(int status, String contentType, String body, Instant now) {
this.status = "COMPLETED";
this.responseStatus = status;
this.contentType = contentType;
this.responseBody = body;
this.updatedAt = now;
}
}
Repository:
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.Instant;
public interface IdempotencyRecordRepository extends JpaRepository<IdempotencyRecord, String> {
long deleteByExpiresAtBefore(Instant now);
}
Step 3) Service: acquire + complete + replay
This service is where you handle concurrency cleanly.
Key points:
- Try to insert an
IN_PROGRESSrecord first - If insert fails (duplicate key), read existing record
- If existing record is
COMPLETED, replay it - If existing record is
IN_PROGRESS, return “in progress”
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
@Service
public class IdempotencyService {
public enum AcquireState { ACQUIRED, IN_PROGRESS, COMPLETED }
public static class AcquireResult {
private final AcquireState state;
private final ResponseEntity<String> replay;
private AcquireResult(AcquireState state, ResponseEntity<String> replay) {
this.state = state;
this.replay = replay;
}
public static AcquireResult acquired() { return new AcquireResult(AcquireState.ACQUIRED, null); }
public static AcquireResult inProgress() { return new AcquireResult(AcquireState.IN_PROGRESS, null); }
public static AcquireResult completed(ResponseEntity<String> replay) { return new AcquireResult(AcquireState.COMPLETED, replay); }
public AcquireState state() { return state; }
public ResponseEntity<String> replay() { return replay; }
}
private final IdempotencyRecordRepository repo;
private final ObjectMapper objectMapper;
public IdempotencyService(IdempotencyRecordRepository repo, ObjectMapper objectMapper) {
this.repo = repo;
this.objectMapper = objectMapper;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public AcquireResult acquire(String internalKey, long ttlSeconds) {
Instant now = Instant.now();
Instant expiresAt = now.plus(ttlSeconds, ChronoUnit.SECONDS);
try {
repo.saveAndFlush(IdempotencyRecord.inProgress(internalKey, now, expiresAt));
return AcquireResult.acquired();
} catch (DataIntegrityViolationException dup) {
Optional<IdempotencyRecord> existing = repo.findById(internalKey);
if (existing.isEmpty()) return AcquireResult.inProgress();
IdempotencyRecord r = existing.get();
if ("COMPLETED".equals(r.getStatus())) {
return AcquireResult.completed(replay(r));
}
return AcquireResult.inProgress();
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void completeSuccess(String internalKey, ResponseEntity<?> response) {
repo.findById(internalKey).ifPresent(r -> {
if (!response.getStatusCode().is2xxSuccessful()) return;
try {
String bodyJson = response.getBody() == null
? ""
: objectMapper.writeValueAsString(response.getBody());
String contentType = response.getHeaders().getContentType() == null
? MediaType.APPLICATION_JSON_VALUE
: response.getHeaders().getContentType().toString();
r.complete(response.getStatusCodeValue(), contentType, bodyJson, Instant.now());
repo.save(r);
} catch (Exception ignore) {
// If serialization fails, don’t cache a broken response.
}
});
}
private ResponseEntity<String> replay(IdempotencyRecord r) {
MediaType ct = r.getContentType() == null
? MediaType.APPLICATION_JSON
: MediaType.parseMediaType(r.getContentType());
int status = (r.getResponseStatus() == null) ? 200 : r.getResponseStatus();
String body = (r.getResponseBody() == null) ? "" : r.getResponseBody();
return ResponseEntity.status(status).contentType(ct).body(body);
}
}
Why REQUIRES_NEW?
If your controller method runs in a transaction (common in service-layer designs), you don’t want the idempotency record to be rolled back together with the business transaction. The “acquire” should be committed early so a retry can see it.
Step 4) The Aspect: wire it into controllers
This aspect:
- reads
Idempotency-Keyfrom request header - builds an internal key to avoid collisions across endpoints/users
- checks/replays/acquires/completes
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.*;
@Aspect
@Component
public class IdempotencyAspect {
private final IdempotencyService service;
public IdempotencyAspect(IdempotencyService service) {
this.service = service;
}
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
HttpServletRequest req = currentRequest();
String key = req.getHeader("Idempotency-Key");
if (key == null || key.isBlank()) {
return ResponseEntity.badRequest()
.body(new SimpleError("idempotency.key.required", "Missing Idempotency-Key"));
}
String internalKey = buildInternalKey(req, key);
IdempotencyService.AcquireResult ar = service.acquire(internalKey, idempotent.ttlSeconds());
if (ar.state() == IdempotencyService.AcquireState.COMPLETED) {
return ar.replay();
}
if (ar.state() == IdempotencyService.AcquireState.IN_PROGRESS) {
return ResponseEntity.status(409)
.body(new SimpleError("idempotency.in_progress", "Request with the same key is in progress"));
}
Object result = pjp.proceed();
// Store only if it's ResponseEntity (recommended controller style)
if (result instanceof ResponseEntity<?> re) {
service.completeSuccess(internalKey, re);
}
return result;
}
private static HttpServletRequest currentRequest() {
RequestAttributes attrs = RequestContextHolder.currentRequestAttributes();
return ((ServletRequestAttributes) attrs).getRequest();
}
private static String buildInternalKey(HttpServletRequest req, String clientKey) {
// Minimum: method + path + client key.
// Better: include authenticated user id if available.
return req.getMethod() + ":" + req.getRequestURI() + ":" + clientKey.trim();
}
public record SimpleError(String error, String message) {}
}
Step 5) Use it in a controller
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
@PostMapping
@Idempotent(ttlSeconds = 24 * 60 * 60)
public ResponseEntity<PaymentResponse> create(@RequestBody CreatePaymentRequest req) {
// Imagine this is a real side effect (charge card, create payment record, etc.)
String paymentId = "pay_" + System.currentTimeMillis();
return ResponseEntity.ok(new PaymentResponse(paymentId, "CREATED"));
}
public record CreatePaymentRequest(@NotBlank String orderId) {}
public record PaymentResponse(String paymentId, String status) {}
}
Now retrying the same request with the same Idempotency-Key returns the same paymentId and status.
Step 6) Cleanup job
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Instant;
@Component
public class IdempotencyCleanupJob {
private final IdempotencyRecordRepository repo;
public IdempotencyCleanupJob(IdempotencyRecordRepository repo) {
this.repo = repo;
}
@Scheduled(cron = "0 0 * * * *") // hourly
public void cleanup() {
repo.deleteByExpiresAtBefore(Instant.now());
}
}
Tests (MockMvc): prove replay works
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class IdempotencyKeyTest {
@Autowired MockMvc mvc;
@Test
void sameKey_replaysSameResponse() throws Exception {
String body = "{\"orderId\":\"o_1\"}";
String r1 = mvc.perform(post("/api/payments")
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", "abc-123")
.content(body))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
String r2 = mvc.perform(post("/api/payments")
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", "abc-123")
.content(body))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
org.junit.jupiter.api.Assertions.assertEquals(r1, r2);
}
}
Practical notes (what teams trip over)
What if the client reuses the same key with a different body?
You can store arequest_hashand reject mismatches (409). This is a great hardening step once the basic version works.Should you cache 4xx errors?
Depends. Many payment/order APIs only cache successful results.
If you cache validation errors, clients get stable errors too—but be careful with sensitive details.-
What if a request crashes after “acquire” but before “complete”?
You’ll have anIN_PROGRESSrecord. Options:- return 409 until TTL expires (simple)
- add “stale in-progress” detection (e.g.,
updated_atolder than N minutes) and allow retry
Don’t use traceId as part of the idempotency key.
Trace IDs change per request. Idempotency keys must be stable across retries.
Wrap-up
Idempotency keys are one of those “boring” backend features that prevent expensive incidents.
You don’t need a fancy distributed lock system. A unique key + stored response is enough for most APIs.
Top comments (0)