Idempotency in System Design: Safe Retries with Java Examples
Idempotency means that performing an operation several times has the same effect as performing it once. An idempotent payment request charges the customer once, even if the client sends it three times because of timeouts. In system design, idempotency is what makes retries safe.
This tutorial explains why duplicates happen, which HTTP methods are idempotent, and the main techniques for making operations idempotent, with three Java examples. It also covers edge cases many guides skip, such as two identical requests arriving at the same moment and a key reused with a different body.
1. What Is Idempotency?
The word comes from mathematics: a function f is idempotent when f(f(x)) = f(x). Setting a light switch to "off" is idempotent; pressing a toggle button is not.
In software, compare "set the balance to 500" with "add 100 to the balance". Running the first twice still leaves 500. Running the second twice adds 200. The first is idempotent; the second needs extra care.
Idempotency is about the effect on the system, not the response. Deleting an order twice leaves it deleted both times, even if the second call returns "not found".
2. Why Idempotency Matters: Retries and Duplicates
In a distributed system, a request can succeed on the server while its response is lost. The client sees only a timeout and cannot tell whether the work happened. The safe reaction is to retry, which is only safe if the operation is idempotent. This is why idempotency is a core part of fault tolerance and the retry pattern.
Duplicates also come from other places:
- Users double-click "Pay" or refresh a page.
- Load balancers, proxies and client libraries retry failed calls automatically.
- Message brokers such as Kafka and RabbitMQ deliver at least once: after a crash, a message may arrive again.
No network can promise "exactly once" delivery. Systems get the same result by combining at-least-once delivery with idempotent processing, so a duplicate does no harm.
3. Idempotent HTTP Methods
The HTTP standard (RFC 9110) defines which methods must be idempotent. A safe method only reads and never changes anything; every safe method is also idempotent.
| Method | Safe | Idempotent | Notes |
|---|---|---|---|
GET, HEAD | Yes | Yes | Reads only |
PUT | No | Yes | Replaces a resource with the given state |
DELETE | No | Yes | A repeat may return 404, but the resource stays deleted |
POST | No | No | Creates or triggers something; make it idempotent with a key |
PATCH | No | Not guaranteed | Idempotent only if the change is absolute, such as setting a field |
These are promises your API must keep. A PUT endpoint that appends to a list breaks the contract, and clients that retry it will create duplicates. The Spring Boot REST API tutorial shows these methods in practice.
4. How to Make Operations Idempotent
4.1 Design Naturally Idempotent Operations
Prefer absolute changes ("set status to SHIPPED") over relative ones ("add 1"), and upserts (insert or update by a known ID) over blind inserts.
4.2 Idempotency Keys
For operations that are not naturally idempotent, such as payments, the client generates a unique idempotency key, usually a UUID, for each logical action and sends it with every attempt, often in an Idempotency-Key header. The server stores the key with the result. A repeat with the same key gets the stored result instead of a second payment. Stripe's API works this way, and the IETF is standardising the header.
4.3 Deduplicating Messages
An idempotent consumer records the ID of each message it has processed and skips IDs it has already seen. This pairs well with at-least-once brokers; the Kafka with Spring Boot tutorial uses it. Kafka's own idempotent producer, enabled by default since Kafka 3.0, does the same for producer retries within a partition.
4.4 Conditional Updates
A version number or ETag lets the server apply a change only if the data has not changed since the client read it. A repeated update then fails the check instead of being applied twice.
5. Idempotency in Java: Code Examples
The examples use only the JDK and need Java 17 or later, because they use records.
5.1 Example 1: Why Retries Need Idempotency
package com.javabytechie.idempotency;
import java.util.HashMap;
import java.util.Map;
public class WalletDemo {
// Not idempotent: every call adds money
static class NaiveWallet {
private long balancePaise;
void addCredit(long amountPaise) {
balancePaise += amountPaise;
}
long balance() {
return balancePaise;
}
}
// Idempotent: each credit has an ID, and an ID is applied only once
static class IdempotentWallet {
private final Map<String, Long> credits = new HashMap<>();
void applyCredit(String creditId, long amountPaise) {
credits.putIfAbsent(creditId, amountPaise);
}
long balance() {
return credits.values().stream().mapToLong(Long::longValue).sum();
}
}
public static void main(String[] args) {
NaiveWallet naive = new NaiveWallet();
naive.addCredit(50_000);
naive.addCredit(50_000); // the client timed out and retried
System.out.println("Naive wallet balance: " + naive.balance() + " paise");
IdempotentWallet wallet = new IdempotentWallet();
wallet.applyCredit("refund-8841", 50_000);
wallet.applyCredit("refund-8841", 50_000); // same retry, same credit ID
System.out.println("Idempotent wallet balance: " + wallet.balance() + " paise");
}
}
Naive wallet balance: 100000 paise
Idempotent wallet balance: 50000 paise
The naive wallet credits the refund twice. The idempotent wallet uses the credit ID as a natural key, so the retry changes nothing.
5.2 Example 2: An Idempotency-Key Payment Service
package com.javabytechie.idempotency;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class IdempotentPaymentService {
public record PaymentRequest(String orderId, long amountPaise) { }
public record PaymentResponse(String paymentId, String status) { }
// The request that first used a key, and its result (possibly still in progress)
private record Entry(PaymentRequest request, CompletableFuture<PaymentResponse> result) { }
private final ConcurrentHashMap<String, Entry> byKey = new ConcurrentHashMap<>();
private final AtomicInteger charges = new AtomicInteger();
public PaymentResponse pay(String idempotencyKey, PaymentRequest request) {
Entry mine = new Entry(request, new CompletableFuture<>());
Entry existing = byKey.putIfAbsent(idempotencyKey, mine); // atomic: only one caller wins
if (existing != null) {
if (!existing.request().equals(request)) {
throw new IllegalArgumentException("idempotency key " + idempotencyKey + " was used for a different request");
}
return existing.result().join(); // replay, or wait for the first attempt
}
try {
PaymentResponse response = charge(request);
mine.result().complete(response);
return response;
} catch (RuntimeException e) {
byKey.remove(idempotencyKey, mine); // a failed attempt may be retried
mine.result().completeExceptionally(e);
throw e;
}
}
private PaymentResponse charge(PaymentRequest request) {
try {
Thread.sleep(100); // the call to the card network
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
int n = charges.incrementAndGet();
return new PaymentResponse("pay_" + n, "PAID " + request.amountPaise() + " paise for " + request.orderId());
}
public int charges() {
return charges.get();
}
}
package com.javabytechie.idempotency;
import com.javabytechie.idempotency.IdempotentPaymentService.PaymentRequest;
import com.javabytechie.idempotency.IdempotentPaymentService.PaymentResponse;
import java.util.concurrent.CompletableFuture;
public class PaymentDemo {
public static void main(String[] args) {
IdempotentPaymentService service = new IdempotentPaymentService();
PaymentRequest order71 = new PaymentRequest("order-71", 49_900);
// 1. First call and a retry with the same key
System.out.println("first call: " + service.pay("key-a1", order71));
System.out.println("retry: " + service.pay("key-a1", order71));
// 2. A double click: two identical requests at the same moment
PaymentRequest order72 = new PaymentRequest("order-72", 12_000);
CompletableFuture<PaymentResponse> click1 = CompletableFuture.supplyAsync(() -> service.pay("key-b2", order72));
CompletableFuture<PaymentResponse> click2 = CompletableFuture.supplyAsync(() -> service.pay("key-b2", order72));
System.out.println("click 1: " + click1.join());
System.out.println("click 2: " + click2.join());
// 3. A client bug: the same key reused for a different amount
try {
service.pay("key-a1", new PaymentRequest("order-71", 99_900));
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
System.out.println("card charges made: " + service.charges());
}
}
first call: PaymentResponse[paymentId=pay_1, status=PAID 49900 paise for order-71]
retry: PaymentResponse[paymentId=pay_1, status=PAID 49900 paise for order-71]
click 1: PaymentResponse[paymentId=pay_2, status=PAID 12000 paise for order-72]
click 2: PaymentResponse[paymentId=pay_2, status=PAID 12000 paise for order-72]
rejected: idempotency key key-a1 was used for a different request
card charges made: 2
Three details make this correct. putIfAbsent reserves the key atomically, so when two clicks arrive together, only one charges the card and the other waits for its result. The stored request is compared, so a reused key with a different amount is rejected. A failed charge removes the key so the client can retry. Four calls produced only two card charges.
5.3 Example 3: An Idempotent Message Consumer
package com.javabytechie.idempotency;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class ShippingEmailConsumer {
record OrderShipped(String eventId, String orderId) { }
// In production this is a database table with a unique key on eventId,
// updated in the same transaction as the work itself
private final Set<String> processedEventIds = new HashSet<>();
private int emailsSent;
void handle(OrderShipped event) {
if (!processedEventIds.add(event.eventId())) {
System.out.println("skipped duplicate " + event.eventId());
return;
}
emailsSent++;
System.out.println("emailed customer: " + event.orderId() + " has shipped");
}
public static void main(String[] args) {
ShippingEmailConsumer consumer = new ShippingEmailConsumer();
List<OrderShipped> delivered = List.of(
new OrderShipped("evt-901", "order-71"),
new OrderShipped("evt-902", "order-72"),
new OrderShipped("evt-901", "order-71")); // redelivered after a consumer restart
delivered.forEach(consumer::handle);
System.out.println("emails sent: " + consumer.emailsSent);
}
}
emailed customer: order-71 has shipped
emailed customer: order-72 has shipped
skipped duplicate evt-901
emails sent: 2
The redelivered event is skipped, so the customer gets one email. In production, the processed IDs live in a database table with a unique key, written in the same transaction as the work.
6. Common Mistakes and Edge Cases
- Check-then-act races: "look up the key, then process" lets two simultaneous requests both pass the check. Reserve the key atomically first, with
putIfAbsentor a unique database constraint. - Saving the key separately from the work: if the server crashes between the payment and saving the key, a retry pays again. Store both in one transaction where possible.
- Ignoring the request body: a reused key with different data usually means a client bug; reject it, typically with 422, instead of returning the old result.
- Keys that expire too soon: keep keys at least as long as clients may retry. Stripe, for example, may remove keys after 24 hours.
- New keys on every retry: generating the key inside the retry loop defeats the purpose; create it once per logical action.
- Assuming idempotent means identical responses: the effect is the same, but a repeated
DELETEmay return 404. - Forgetting downstream calls: an idempotent API that sends a non-idempotent email or webhook on each attempt still duplicates side effects. In a saga, every step and compensation must be idempotent.
7. Best Practices
- Make every write retry-safe, either naturally or with an idempotency key.
- Generate keys on the client, once per user action, and reuse them on every retry.
- Reserve keys atomically with a unique constraint, and handle in-progress duplicates by waiting or returning 409.
- Store the key, request fingerprint and result together with the business change.
- Use idempotent consumers for every at-least-once message queue.
- Document which endpoints are idempotent and test them by sending duplicates on purpose.
8. Summary
- An idempotent operation has the same effect no matter how many times it runs.
- Timeouts, double clicks and at-least-once messaging all create duplicates.
GET,PUTandDELETEare idempotent by definition;POSTis not.- Use natural keys, idempotency keys, message deduplication or conditional updates.
- Reserve keys atomically, compare request bodies, and store keys with the work.
Idempotency turns retries from a risk into a safety net, which is why it belongs in the design of every payment, order and message handler. Open the FAQ tab for idempotency interview questions.
Idempotency Interview Questions and Answers
Q1. What is idempotency in system design?
Idempotency means that performing an operation more than once has the same effect as performing it once. It lets clients safely retry requests after timeouts or errors without causing duplicate payments, orders or messages.
Q2. Which HTTP methods are idempotent?
GET, HEAD, OPTIONS, TRACE, PUT and DELETE are idempotent according to the HTTP standard, RFC 9110. POST is not idempotent, and PATCH is not guaranteed to be.
Q3. What is the difference between safe and idempotent methods?
A safe method only reads data and never changes it, like GET. An idempotent method may change data, but repeating it has no further effect, like PUT or DELETE. Every safe method is idempotent, but not every idempotent method is safe.
Q4. What is an idempotency key?
An idempotency key is a unique value, usually a UUID, that the client sends with a request and reuses on every retry of the same action. The server stores the key with the result, so a repeated request returns the stored result instead of running the action again.
Q5. How do you make a POST request idempotent?
Have the client send an idempotency key, often in an Idempotency-Key header. The server atomically reserves the key, processes the request once, stores the response with the key, and returns that stored response for any repeat with the same key.
Q6. What happens if two requests with the same idempotency key arrive at the same time?
Only one may process the request. The server reserves the key atomically, for example with a unique database constraint or putIfAbsent, and the other request either waits for the result or receives a 409 Conflict and retries later.
Q7. What should happen if the same key is sent with a different request body?
The server should reject it, commonly with 422 Unprocessable Content, because it usually means a client bug. Returning the old result would hide the problem and could confirm an action the client never meant to take.
Q8. Is DELETE idempotent if the second call returns 404?
Yes. Idempotency is about the effect on the server, not the response code. After the first call the resource is gone, and every later call leaves it gone, even if the response changes from 200 or 204 to 404.
Q9. How does idempotency relate to exactly-once delivery?
Networks and message brokers can only guarantee at-least-once delivery, so duplicates are always possible. Combining at-least-once delivery with idempotent processing gives an effectively-once result, which is what most systems mean by exactly once.
Q10. How do you make a message consumer idempotent?
Give every message a unique ID and store the IDs of processed messages, ideally in a database table with a unique constraint, in the same transaction as the work. When a message arrives again, the consumer sees its ID and skips it.