Introduction
Duplicate requests are one of the most damaging bugs in production REST APIs — a user double-clicks “Pay Now,” a mobile client retries after a network blip, or a load balancer resends a request that never got acknowledged. Without protection, this can mean double-charged customers, duplicate orders, or duplicate emails sent.
What makes this problem deceptively hard is that a naive fix — a simple “check if it exists, then insert” — has a race condition that only shows up under real concurrent load, meaning it can pass every test in a staging environment and still fail in production the first time two retries land within milliseconds of each other. This guide covers the concrete mechanisms for detecting and blocking duplicate requests, including the failure modes each mechanism alone doesn’t cover.
Prerequisites
- Spring Data JPA basics
- Database transactions and unique constraints
- Basic Redis usage
Related reading:
Learning Objectives
- Understand every common source of duplicate requests in production.
- Implement idempotency keys correctly, including the database-level backstop.
- Apply distributed locking for multi-instance deployments.
- Know when client-side prevention is and isn’t sufficient.
Table of Contents
- Why Duplicate Requests Happen
- Idempotency Keys
- Database Unique Constraints as a Backstop
- Distributed Locking for Concurrent Duplicates
- Client-Side Prevention
- Production Case Studies
- Architect’s Perspective: Trade-offs, Scale & War Story
- Common Mistakes
- Best Practices Checklist
- Troubleshooting Decision Tree
- Interview Questions
- FAQs
Why Duplicate Requests Happen
- Client retries — a mobile app or frontend retries automatically after a timeout, not knowing whether the original request actually succeeded server-side.
- Double-clicks/double-taps — a user submits a form twice before the UI disables the button.
- Load balancer/proxy retries — some gateways retry a request automatically on a connection reset.
- At-least-once messaging — Kafka consumers and other async processing can redeliver the same message.
Client ──▶ request #1 ──▶ Server (processes, but response lost in transit)
Client ──▶ request #2 (retry) ──▶ Server (processes again — duplicate!)
Idempotency Keys
The client generates a unique key per logical operation (usually a UUID) and sends it on every attempt, including retries. The server stores which keys it has already processed and returns the original result instead of reprocessing:
@PostMapping("/payments")
public ResponseEntity<PaymentResponse> createPayment(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody @Valid PaymentRequest request) {
Optional<PaymentResponse> existing = idempotencyService.findResponse(idempotencyKey);
if (existing.isPresent()) {
return ResponseEntity.ok(existing.get());
}
PaymentResponse response = paymentService.process(request);
idempotencyService.storeResponse(idempotencyKey, response);
return ResponseEntity.ok(response);
}
Store idempotency keys with a TTL (e.g., 24 hours) — keeping them forever is unnecessary and bloats the table.
Database Unique Constraints as a Backstop
Application-level checks alone have a race-condition window. A database-level unique constraint closes that gap:
ALTER TABLE idempotency_record
ADD CONSTRAINT uq_idempotency_key UNIQUE (idempotency_key);
try {
idempotencyService.storeResponse(idempotencyKey, response);
} catch (DataIntegrityViolationException e) {
return idempotencyService.findResponse(idempotencyKey)
.map(ResponseEntity::ok)
.orElseThrow(() -> e);
}
Distributed Locking for Concurrent Duplicates
@PostMapping("/payments")
public ResponseEntity<PaymentResponse> createPayment(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody @Valid PaymentRequest request) {
String lockKey = "lock:payment:" + idempotencyKey;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (Boolean.FALSE.equals(acquired)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new PaymentResponse("Request already in progress"));
}
try {
return ResponseEntity.ok(processWithIdempotency(idempotencyKey, request));
} finally {
redisTemplate.delete(lockKey);
}
}
Client-Side Prevention
Server-side protection is mandatory, but reducing duplicate attempts at the source lowers load and improves UX: disable submit buttons immediately on click, and use exponential backoff with a capped retry count instead of immediate retries on the client.
Production Case Studies
Case Study 1 – Race Condition Bypassing an Application-Level Check
Symptoms: Rare but real duplicate orders under high concurrency, despite an “exists check then insert” guard in the service layer.
Investigation: Load testing with concurrent identical requests reproduced the issue reliably — two threads both passed the exists check before either had committed its insert.
Root Cause: No database-level uniqueness constraint backing the application-level check.
Solution: Added a unique constraint on the idempotency key column and handled the resulting DataIntegrityViolationException by returning the existing record.
Case Study 2 – Failover-Induced Replica Lag Bypassing Idempotency
Symptoms: A single duplicate charge occurred during a regional database failover event.
Investigation: The retry’s idempotency-key lookup was served from a read replica that hadn’t yet caught up after the failover, so it incorrectly reported “not yet processed.”
Root Cause: Idempotency checks were reading from a replica with eventual, not immediate, consistency.
Solution: Routed idempotency-key reads specifically to the primary database, accepting the added latency for this one code path as a deliberate consistency-over-performance trade-off.
Architect’s Perspective: Trade-offs, Scale & War Story
Trade-off Analysis: Consistency Cost of Idempotency Storage
The idempotency-record write and the business-effect write must be atomic together, or you can end up with a stored key pointing to a rolled-back effect, or a committed effect with no stored key. When they don’t share a datastore, you’re accepting a distributed consistency gap that needs an explicit reconciliation strategy.
At Scale: Idempotency Store as a New Bottleneck
At high request volume, the idempotency check itself becomes a new latency and scaling consideration. This pushes teams toward a dedicated, horizontally-scaled idempotency store rather than reusing the primary transactional database.
When NOT to Add Idempotency Protection
Low-stakes, naturally-safe-to-duplicate operations don’t justify the storage, complexity, and latency cost of full idempotency infrastructure. Reserve it for operations with real business or financial consequence on duplication.
Observability: Detecting Silent Duplicate Leakage
Monitor the ratio of idempotency-key-collision-avoided requests to total requests over time — a sudden drop suggests client-side key generation broke; a sudden spike suggests a retry storm worth investigating.
Production War Story
See Case Study 2 above — the failover/replica-lag scenario is the clearest example of idempotency protection failing not because the logic was wrong, but because an unstated consistency assumption (reads always reflect the latest write) broke during an infrastructure event nobody had modeled into the idempotency design.
Common Mistakes
| Mistake | Impact | Better Practice |
|---|---|---|
| App-level “check then insert” with no DB constraint | Race condition under concurrency | Add a database unique constraint as backstop |
| Using request body instead of dedicated idempotency key | Legitimately different requests with identical bodies collide | Require a client-generated idempotency key |
| No TTL on idempotency records | Unbounded table growth | Expire records after a reasonable window (e.g., 24h) |
| Trusting client-side button-disabling alone | No protection against network-level retries | Always enforce server-side idempotency too |
| Returning an error on a duplicate instead of the original response | Breaks legitimate retry semantics | Return the original success response on duplicate detection |
Best Practices Checklist
- [ ] Idempotency key required on all non-idempotent, side-effecting POST endpoints
- [ ] Database unique constraint backing the idempotency key check
- [ ] Distributed lock for multi-instance deployments
- [ ] TTL/cleanup job for expired idempotency records
- [ ] Duplicate requests return the original success response, not an error
- [ ] Idempotency-key reads routed to primary datastore, not a lagging replica
Troubleshooting Decision Tree
Duplicate processing observed
│
├─ Single instance, low concurrency? → App-level idempotency key check is likely sufficient
├─ Multiple instances / high concurrency? → Add DB unique constraint + distributed lock
├─ Duplicates from async/Kafka consumers? → Make consumer logic idempotent at the data layer
├─ Duplicates during failover events? → Check for replica-lag reads on idempotency checks
└─ Duplicates from client double-submits? → Add idempotency key + disable-on-click client fix
Interview Questions
- Why is an application-level idempotency check alone insufficient to prevent duplicates?
- How does a database unique constraint close the race-condition window that an application check misses?
- When would you need a distributed lock in addition to an idempotency key?
- What should a duplicate request return — an error, or the original response? Why?
- How can database failover cause idempotency checks to fail even when the logic is otherwise correct?
FAQs
Q: Is using a GET-then-POST pattern enough to prevent duplicates?
No — there’s always a race window between the check and the write. A database-level constraint is required to fully close it.
Q: Should idempotency keys be generated by the client or the server?
By the client — the same key is reused across retries of the same logical operation, which only the client can guarantee.
Continue Reading on SpringBootFixes
Conclusion
Preventing duplicate requests in production requires layering defenses — an idempotency key alone is a good start, but only a database-level constraint closes the race-condition window, and only a consistency-aware read path survives infrastructure events like failover. Treat this as a defense-in-depth problem, not a single design pattern to apply once.
