Hook: Duplicate requests are like a customer pressing the elevator button three times because the door is slow — you do not want the building to arrive three times.
Question: How do you handle duplicate requests in Spring Boot?
Answer: Use an idempotency key, which is a unique token the client sends with a request so the server can recognize retries. The first request does the work; later requests with the same key return the same result instead of doing the action again. For important POST calls like payments or order creation, I also protect the database with a unique constraint, so even if two app instances race, only one write succeeds.
Interview-Ready Answer: I handle duplicate requests by making the operation idempotent. In practice, I ask the client to send an Idempotency-Key, store that key with the request hash and result, and on a retry I return the cached response instead of running the business logic again. If two requests race, I also rely on a database unique constraint or atomic cache write so the duplicate cannot slip through. That gives me safe retries without double-charging, double-ordering, or duplicate side effects.
When people say “duplicate request,” they usually mean the same action reaches the server more than once because of a timeout, a retry, a double click, a mobile network glitch, or a load balancer retry. In Spring Boot, the danger is not the HTTP call itself; the danger is the side effect behind it, such as creating an order, charging a card, sending an email, or reserving inventory.
The key idea is idempotency, meaning repeating the same request produces the same final effect. A GET is naturally idempotent because it reads data. A POST is usually not, so you must design it to behave safely when retried.
Idempotency-Key, which is a unique token for one business action.409 Conflict, because that usually means a client bug or a reused key.There are several ways to stop duplicates, and the best answer depends on where the risk comes from. A frontend button disable helps user experience, but it does not protect the backend. A database unique index is a strong final safety net, but it only helps once you reach the database. An idempotency key protects the whole request path.
| Approach | Best when | Trade-off |
|---|---|---|
| Idempotency key | POST retries | Needs shared store |
| Unique index | One-row writes | DB-only protection |
| Distributed lock | Hot resources | Harder to operate |
| Queue dedupe | Async workers | Not instant replay |
Memory rule: one action, one key, one result. If you remember that, you will explain the solution cleanly under pressure.
A hash-map or Redis lookup is usually fast enough for API traffic. In-memory lookups are effectively constant time, but they only work on one JVM and are lost on restart. Redis gives shared state across instances and a TTL, but adds a network hop, often around 1-5 ms in a healthy environment. A database unique constraint is slower than a cache lookup, but it is a strong correctness barrier.
Storage is the trade-off. If you keep 1 million keys and each record stores a small hash plus a response, memory can grow into hundreds of megabytes once object overhead is included. In real systems, teams often keep payment idempotency keys for 24 hours and form-submission keys for 5-15 minutes, which is long enough for retries but short enough to avoid unbounded growth.
Version note: the Spring Boot pattern itself is stable, but if you add validation in Boot 3, use jakarta.validation instead of javax.validation. The duplicate-request design does not change; only some imports do.
Do not blindly cache failures. If the downstream gateway timed out before you knew whether it succeeded, you may need a status like PROCESSING and a retry-safe reconciliation path. Also, remember that “client timed out” does not mean “server failed.” The server may have completed the payment and just lost the response, which is exactly why idempotency matters.
For multi-instance deployments, an in-memory map is only a demo. Production systems usually move the key store to Redis or the database so every node sees the same history.
Real-World Story: Imagine a checkout service in an e-commerce app. A user taps Pay, the mobile network stalls, and the app times out after 2 seconds. The user taps again, and the phone also retries automatically. Without duplicate protection, the payment gateway can receive two charge attempts, the order service can create two orders, and the warehouse may pack two boxes.
With idempotency, both requests carry the same key. The first request reserves the key, completes the charge, stores the result, and returns success. The second request sees the key already completed and gets the same payment response back. The user sees one order, support sees one transaction, and finance avoids a refund fire drill.
What goes wrong when teams misunderstand this? They often think disabling the submit button is enough. It is not. In production, the symptom is duplicate rows, double confirmation emails, repeated POST /payments logs, and angry users saying “I only clicked once.” The incident usually shows up first as support tickets and a spike in refund requests, not as a clean application error. That is why duplicate handling is a correctness problem, not just a UI problem.
package com.example.duplicaterequests;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@SpringBootApplication
public class DuplicateRequestApplication {
public static void main(String[] args) {
SpringApplication.run(DuplicateRequestApplication.class, args);
}
}
record PaymentRequest(String accountId, int amountCents, String currency) {}
record PaymentResponse(String paymentId, String status, int amountCents, String currency, Instant processedAt) {}
enum EntryStatus {
PROCESSING,
COMPLETED
}
class IdempotencyEntry {
final String requestHash;
volatile EntryStatus status = EntryStatus.PROCESSING;
volatile PaymentResponse response;
IdempotencyEntry(String requestHash) {
this.requestHash = requestHash;
}
void complete(PaymentResponse response) {
this.response = response;
this.status = EntryStatus.COMPLETED;
}
}
@RestController
@RequestMapping("/payments")
class PaymentController {
private final PaymentService paymentService;
PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@PostMapping
public ResponseEntity<PaymentResponse> createPayment(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody PaymentRequest request) {
return paymentService.createPayment(idempotencyKey, request);
}
}
@Service
class PaymentService {
private final Map<String, IdempotencyEntry> idempotencyStore = new ConcurrentHashMap<>();
public ResponseEntity<PaymentResponse> createPayment(String idempotencyKey, PaymentRequest request) {
validate(request);
String hash = requestHash(request);
IdempotencyEntry fresh = new IdempotencyEntry(hash);
// Atomic reservation: only the first request for a key gets to do the work.
IdempotencyEntry existing = idempotencyStore.putIfAbsent(idempotencyKey, fresh);
if (existing == null) {
try {
PaymentResponse response = processPayment(request);
fresh.complete(response);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
} catch (RuntimeException ex) {
// If the business step fails before commit, remove the reservation so a retry can try again.
idempotencyStore.remove(idempotencyKey, fresh);
throw ex;
}
}
if (!existing.requestHash.equals(hash)) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"Same idempotency key was reused for a different request body");
}
if (existing.status == EntryStatus.PROCESSING) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"Request is still being processed; retry with the same key later");
}
// Replay the exact same result instead of charging twice.
return ResponseEntity.ok(existing.response);
}
private void validate(PaymentRequest request) {
if (request.accountId() == null || request.accountId().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "accountId is required");
}
if (request.amountCents() <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "amountCents must be positive");
}
if (request.currency() == null || request.currency().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "currency is required");
}
}
private PaymentResponse processPayment(PaymentRequest request) {
try {
Thread.sleep(150);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Interrupted while processing");
}
// In real life this would call a payment gateway and write to a database in one transaction.
return new PaymentResponse(
UUID.randomUUID().toString(),
"CAPTURED",
request.amountCents(),
request.currency(),
Instant.now());
}
private String requestHash(PaymentRequest request) {
return request.accountId() + "|" + request.amountCents() + "|" + request.currency();
}
}Follow-up & Tricky Questions:
@Transactional solve duplicates? No. It prevents partial DB updates in one transaction, but it does not stop the same HTTP call from being run twice.Common Mistakes:
409 Conflict.Memory Hook: Think of an idempotency key as a theater ticket stub: the first scan admits you, the second scan proves you already entered, so nobody gets two seats.
Cheat Sheet:
Idempotency-Key for POST requests with side effects.Practice Tasks:
/payments endpoint using a ConcurrentHashMap.