00:00

Idempotency in System Design: Safe Retries with Java Examples

Last updated:

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.

MethodSafeIdempotentNotes
GET, HEADYesYesReads only
PUTNoYesReplaces a resource with the given state
DELETENoYesA repeat may return 404, but the resource stays deleted
POSTNoNoCreates or triggers something; make it idempotent with a key
PATCHNoNot guaranteedIdempotent 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.

How a server handles an idempotency key A request arrives with an idempotency key. If the key is new, the server reserves it as in progress, processes the payment once, saves the response with the key and returns it. If the key was seen before and the request body is the same, the server returns the saved response, or waits while the first attempt is still running. If the body is different, the server rejects the request with 422. Request with Idempotency-Key Key seen before? no yes Reserve key: in progress Process the payment once Save response with key and return it Same request body? yes no Return saved response or wait if still running Reject 422
Figure 1: a new key is processed once and its response saved; a repeated key gets the saved response, and a reused key with a different body is rejected.

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

WalletDemo.java
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");
    }
}
Output
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

IdempotentPaymentService.java
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();
    }
}
PaymentDemo.java
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());
    }
}
Output
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

ShippingEmailConsumer.java
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);
    }
}
Output
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 putIfAbsent or 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 DELETE may 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, PUT and DELETE are idempotent by definition; POST is 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.