Introduction
Idempotency means calling an operation once has the same effect as calling it multiple times. It’s a core REST design principle, not just a fix for accidental duplicate requests — it’s what makes retries safe by design across the whole system, from HTTP clients to message consumers.
The subtlety that trips up experienced teams is that idempotency isn’t a property you can verify by reading the HTTP method annotation — a @PutMapping method can still be non-idempotent if its implementation performs an additive update, and a message consumer can look idempotent in code review while still double-applying a side effect that wasn’t part of the primary business logic being reviewed. This guide covers idempotency as an architectural property, distinct from the request-level duplicate-prevention mechanics covered separately.
Prerequisites
- HTTP method semantics (GET, PUT, POST, DELETE, PATCH)
- Spring Data JPA basics
- Kafka consumer basics
Related reading:
Learning Objectives
- Understand which HTTP methods are idempotent by specification and why.
- Design PUT, DELETE, and PATCH endpoints that are genuinely idempotent in implementation, not just by annotation.
- Build idempotent Kafka consumers that tolerate at-least-once delivery.
- Recognize side effects that silently break an otherwise-idempotent endpoint.
Table of Contents
- What Idempotency Actually Means
- Idempotency by HTTP Method
- Designing an Idempotent PUT
- Designing an Idempotent DELETE
- Making POST Idempotent
- Idempotent Message Consumers
- Production Case Studies
- Architect’s Perspective: Trade-offs, Scale & War Story
- Common Mistakes
- Best Practices Checklist
- Decision Tree
- Interview Questions
- FAQs
What Idempotency Actually Means
An operation is idempotent if performing it N times produces the same server state as performing it once. This is a property of the operation’s effect on state — not of whether the response body looks the same each time.
Idempotent: PUT /users/5 {"name":"Alex"} → same final state, called 1x or 5x
Not idempotent: POST /orders {"item":"X"} → 5 calls create 5 separate orders
Idempotency by HTTP Method
| Method | Idempotent? | Notes |
|---|---|---|
| GET | Yes | Read-only, no state change |
| PUT | Yes | Full replace — same input, same result |
| DELETE | Yes | Deleting an already-deleted resource is still “deleted” |
| PATCH | Depends | Idempotent only if the patch describes an absolute state, not a relative delta |
| POST | No, by default | Typically creates a new resource on every call |
These are HTTP specification conventions, not automatic guarantees — a poorly written Spring Boot @PutMapping handler can still be non-idempotent if it appends instead of replaces.
Designing an Idempotent PUT
@PutMapping("/users/{id}")
public ResponseEntity<UserResponse> updateUser(@PathVariable Long id,
@RequestBody @Valid UserUpdateRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
user.setName(request.getName());
user.setEmail(request.getEmail());
userRepository.save(user);
return ResponseEntity.ok(UserResponse.from(user));
}
The tell-tale sign of a broken idempotent PUT is any logic that adds to existing state (user.setBalance(user.getBalance() + request.getAmount())) rather than setting it absolutely.
Designing an Idempotent DELETE
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userRepository.findById(id).ifPresent(userRepository::delete);
return ResponseEntity.noContent().build(); // 204 whether it existed or not
}
A common mistake is returning 404 on the second DELETE call for an already-deleted resource — “the resource is gone” is true both times, so returning 204 consistently keeps the operation idempotent.
Making POST Idempotent
POST is not idempotent by default. To make it safe to retry, combine it with an idempotency key — the storage and locking mechanics are covered in Prevent Duplicate REST API Requests in Spring Boot. The distinction: that article is about detecting and blocking accidental duplicates; this section is about designing the endpoint’s contract so retries are safe in the first place.
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(
@RequestHeader("Idempotency-Key") String key,
@RequestBody @Valid OrderRequest request) {
OrderResponse response = idempotentOrderService.createOrGetExisting(key, request);
return ResponseEntity.ok(response);
}
Idempotent Message Consumers
@KafkaListener(topics = "order-events")
public void handleOrderEvent(OrderEvent event) {
if (processedEventRepository.existsById(event.getEventId())) {
return;
}
orderService.applyEvent(event);
processedEventRepository.save(new ProcessedEvent(event.getEventId(), Instant.now()));
}
Wrap the “check, apply, mark processed” sequence in a single transaction so a crash between steps doesn’t leave the event half-applied and unmarked.
Production Case Studies
Case Study 1 – Idempotent Primary Effect, Non-Idempotent Side Effect
Symptoms: A downstream reconciliation report started showing duplicate audit entries after a retry storm.
Investigation: The endpoint’s primary effect (setting inventory level) was correctly idempotent, but a buried side effect — appending an audit-log entry — ran on every call regardless of whether the primary state actually changed.
Root Cause: Idempotency review scoped to the endpoint’s headline purpose, not every effect of the operation.
Solution: Made the audit-log write conditional on an actual state change, and added a checklist item requiring every side effect — not just the primary one — to be reviewed for idempotency.
Case Study 2 – Non-Atomic Check-and-Mark in a Kafka Consumer
Symptoms: Occasional duplicate order-processing under consumer restarts.
Investigation: The consumer applied the event, then separately marked it processed in a follow-up call — a crash between the two steps left the event applied but not marked, so redelivery reapplied it.
Root Cause: “Check, apply, mark” sequence not wrapped in a single transaction.
Solution: Combined the apply and mark-processed steps into one transactional boundary.
Architect’s Perspective: Trade-offs, Scale & War Story
Trade-off Analysis: Idempotency vs. Natural Business Semantics
Forcing idempotency onto a naturally non-idempotent operation requires redesigning its contract — switching from a relative delta to an absolute target state. This trades API ergonomics for retry safety; a mature design often offers both, as separate endpoints with clearly documented semantics.
At Scale: Idempotency in Event-Driven Architectures Compounds
In a chained event system, each consumer needs independent idempotency handling — a redelivery without idempotency at every hop can multiply effects exponentially down the chain, not just double them.
When Idempotency Isn’t Worth the Design Cost
For purely internal, low-consequence, easily-reversible operations, the cost of getting idempotency exactly right can exceed the cost of an occasional harmless duplicate write.
Observability: Verifying Idempotency Actually Holds Under Chaos
Chaos-engineering practices — deliberately redelivering the same message N times in staging and asserting the final state matches a single delivery — catch subtle bugs code review alone misses.
Production War Story
See Case Study 1 above — it’s the clearest illustration that “idempotent” is a claim about the whole operation, not just its primary documented purpose, and that code review needs to scope idempotency checks accordingly.
Common Mistakes
| Mistake | Impact | Better Practice |
|---|---|---|
| PUT implemented as an incremental/additive update | Not actually idempotent despite the annotation | Perform full state replacement |
| DELETE returns different status for already-deleted resource | Breaks client retry logic | Return consistent status regardless of prior existence |
| Assuming POST is idempotent without a key | Retries create duplicates | Add an explicit idempotency key |
| Consumer applies logic before marking processed, non-atomically | Crash window causes duplicate application | Wrap check-apply-mark in one transaction |
| Reviewing only the primary effect for idempotency | Side effects silently break the guarantee | Review every effect of the operation, not just the headline one |
Best Practices Checklist
- [ ] PUT handlers perform full state replacement, not incremental updates
- [ ] DELETE returns a consistent status code regardless of prior existence
- [ ] POST endpoints with side effects support an idempotency key
- [ ] Message consumers check-and-mark processed events inside a single transaction
- [ ] PATCH endpoints documented explicitly as idempotent or not
- [ ] Every side effect of an operation reviewed for idempotency, not just the primary one
Decision Tree
Designing an endpoint
│
├─ Read-only? → GET, naturally idempotent
├─ Full resource replace? → PUT, ensure it sets absolute state
├─ Partial update? → PATCH, document idempotency explicitly
├─ Deletion? → DELETE, return consistent status regardless of prior state
└─ Creates a resource / has side effects? → POST + idempotency key
Interview Questions
- Why is PUT idempotent but POST is not, by HTTP specification convention?
- Can PATCH be idempotent? Under what condition does it fail to be?
- How would you make a payment-creation POST endpoint idempotent?
- Why must “check if processed” and “mark as processed” happen in the same transaction for a Kafka consumer?
- What’s the difference between idempotency and duplicate-request prevention?
FAQs
Q: Is idempotency the same thing as being safe (read-only)?
No — GET is both safe and idempotent, but PUT and DELETE are idempotent while still changing server state.
Q: Do I need idempotency keys for GET requests?
No — GET is idempotent by definition since it doesn’t modify state.
Continue Reading on SpringBootFixes
- Prevent Duplicate REST API Requests in Spring Boot
- Spring Boot REST API Slow Response
- Handle Large Request & Response Payloads in Spring Boot
Conclusion
Idempotency is a property of an operation’s entire effect on state, not a guarantee that comes free with an HTTP method annotation. Design absolute-state PUT/DELETE handlers, protect POST with idempotency keys, wrap message-consumer check-and-mark logic in a single transaction, and — critically — review every side effect an operation has, not just its primary purpose.
