Prevent Duplicate REST API Requests in Spring Boot (Production Guide)

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

  • 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

MistakeImpactBetter Practice
App-level “check then insert” with no DB constraintRace condition under concurrencyAdd a database unique constraint as backstop
Using request body instead of dedicated idempotency keyLegitimately different requests with identical bodies collideRequire a client-generated idempotency key
No TTL on idempotency recordsUnbounded table growthExpire records after a reasonable window (e.g., 24h)
Trusting client-side button-disabling aloneNo protection against network-level retriesAlways enforce server-side idempotency too
Returning an error on a duplicate instead of the original responseBreaks legitimate retry semanticsReturn 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

  1. Why is an application-level idempotency check alone insufficient to prevent duplicates?
  2. How does a database unique constraint close the race-condition window that an application check misses?
  3. When would you need a distributed lock in addition to an idempotency key?
  4. What should a duplicate request return — an error, or the original response? Why?
  5. 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.

External References

Leave a Comment

Your email address will not be published. Required fields are marked *