What Is Idempotency?

An operation is idempotent if performing it many times produces the same result as performing it once. Sending the same request twice leaves the system in the same state as sending it once. Idempotency is what makes it safe to retry a request when a network failure has left the outcome uncertain.

What does idempotency mean?

The term is borrowed from mathematics, where a function is idempotent if applying it to its own output changes nothing: f(f(x)) = f(x). Taking an absolute value is idempotent — abs(abs(x)) always equals abs(x). Adding one is not: every application moves the result further along.

In software the same idea applies to state. Consider two ways of updating an account balance:

  • SET balance = 100 is idempotent. Run it once or fifty times and the balance is 100.
  • balance = balance + 100 is not. Every repetition changes the result.

A useful everyday comparison is a lift call button against a light switch on a toggle. Pressing the call button ten times summons the lift exactly as pressing it once does — the button is idempotent. Flipping a toggle ten times leaves the light in a different state than flipping it once.

Idempotent vs idempotency

The two words describe the same concept from different angles. Idempotent is the adjective applied to an operation, endpoint or function. Idempotency is the property that operation has. An idempotent endpoint exhibits idempotency.

Idempotence is a third spelling of that same property, and the one mathematics prefers. All three are correct and none carries a distinct technical meaning — the split is disciplinary rather than substantive. Idempotence dominates in algebra and formal writing; idempotency is the form that took hold in software engineering and API documentation, largely on the back of the Idempotency-Key header. If a specification or a colleague uses one and you use the other, you are talking about the same thing.

Why does idempotency matter?

Idempotency exists to solve one specific problem: the client cannot tell the difference between a request that never arrived and a request that succeeded but whose response was lost.

When a connection drops mid-request, the calling application knows only that it did not receive a response. The server may have done nothing. It may equally have charged a card, dispatched an order or written a row, and then failed to deliver the confirmation. From the client's position these two outcomes look identical.

The client's only reasonable action is to retry. If the operation is idempotent, retrying is safe — the second request either does nothing or returns the original result. If it is not, retrying charges the customer twice.

The same problem appears wherever delivery is guaranteed at least once rather than exactly once. Message queues redeliver on consumer failure, and Redis Streams hold any entry a consumer never acknowledged in the pending entries list, where another consumer can claim it. Job schedulers rerun tasks after a crash. Service meshes retry automatically on timeout. The outbox pattern guarantees an event is published, not that it is published once. In each case the infrastructure assumes your handler can absorb a duplicate, and idempotency is the property that lets it.

Which HTTP methods are idempotent?

HTTP semantics, defined in RFC 9110, specify which request methods are expected to be idempotent:

MethodIdempotentSafe
GETYesYes
HEADYesYes
OPTIONSYesYes
PUTYesNo
DELETEYesNo
POSTNoNo
PATCHNoNo

Safe and idempotent are often confused. A safe method does not modify state at all. An idempotent method may modify state, but repeating it causes no further change. PUT and DELETE are idempotent without being safe.

One caveat matters more than the table does: these are specification expectations, not guarantees about your code. A PUT handler that appends a row to an audit table on every call is not idempotent, whatever the RFC says. Idempotency is a property you implement, not one the method name confers.

What is an idempotency key?

POST is not idempotent by definition, yet it is what most APIs use to create resources and take payments — precisely the operations where duplicates cause the most damage. An idempotency key is the standard way to close that gap, and it is why idempotency is treated as a core API reliability property rather than an implementation detail: it is what allows a client, an SDK, a gateway or a service mesh to retry a failed call without asking your permission first.

The client generates a unique value for each logical operation and sends it with the request, conventionally in an Idempotency-Key header. The server records the key alongside the result. If a request arrives carrying a key it has already seen, the server skips the work and returns the stored response.

The key must be generated by the client, must be unique per operation, and must not be reused for a request with a different payload. A UUID is the usual choice. One design point is easy to miss: Stripe stores the status code and body of the first request whether it succeeded or failed, so a retry returns the original error rather than silently trying again. Stripe popularised the pattern, and it has since been described in an IETF Internet-Draft, The Idempotency-Key HTTP Header Field. That draft remains unratified after several years, so treat the header as a widely adopted convention rather than a formal standard.

Some implementations also store a fingerprint of the request body next to the key. If the same key arrives with a different payload, the server can reject it outright rather than silently returning a response belonging to a different request.

How do you implement idempotency?

Implementing idempotency keys raises three practical problems.

Where the keys live. The store needs three things: fast lookup on every request, an atomic insert that cannot be won twice, and automatic expiry. That describes an in-memory key-value store with per-entry TTL.

Concurrency. Two duplicate requests can arrive simultaneously. Both check for the key, both find nothing, and both proceed. A read followed by a write is a race, not a check — the claim must be atomic, or guarded by a distributed lock.

How long to retain keys. Too short and the duplicate window reopens before the client has finished retrying. Too long and the store grows without bound. Twenty-four hours is a common default, and is what Stripe uses. Be clear about what expiry means: once a key falls out of the store the protection is gone, and a request carrying that key is treated as new. Nothing warns you. The window therefore has to outlast the slowest retry any client, SDK, gateway or scheduler in front of you might attempt — which is a question about your callers' retry policy, not about your storage budget.

Storing idempotency keys in Java

On Redis the primitive for this is SET key value NX EX ttl — write the key only if it does not already exist, and give it a lifetime in the same command. Because the check and the write are one command, there is no window between them. SETNX followed by a separate EXPIRE is the older two-command idiom and should not be used for this: a process that dies between the two leaves a key that never expires, and the duplicate window never reopens.

Using Redisson, setIfAbsent is that command, and a single call both checks for the key and claims it:

RBucket<String> claim = redisson.getBucket("idempotency:" + idempotencyKey);

// setIfAbsent is atomic. It returns false if the key already exists,
// so two concurrent duplicates can never both pass this check.
if (!claim.setIfAbsent(IN_PROGRESS, Duration.ofHours(24))) {
    String stored = claim.get();
    if (IN_PROGRESS.equals(stored)) {
        throw new RequestInFlightException(idempotencyKey);   // answer 409
    }
    return deserialize(stored);          // duplicate: return the original result
}

PaymentResult result = charge(request);
claim.set(serialize(result), Duration.ofHours(24));   // replace the marker
return result;

Because the check and the write happen in one operation, there is no window between them for a second request to slip through. To keep the key and the response together with a per-entry expiry, RMapCacheNative stores both in a single structure and uses native server commands for expiration rather than a scheduled eviction task. It requires Valkey 9.0+ or Redis 7.4+ — and a few of its newer operations need Redis 8.0+, which Valkey 9.0 already satisfies — so check your server version before reaching for it.

Handling concurrent duplicates

When the work spans several steps that must not interleave — writing to a database, publishing an event, then recording the result — an atomic claim is not enough on its own. A lock keyed on the idempotency key serialises the whole operation:

// tryLock is interruptible, so the enclosing method declares it
public PaymentResult handle(Request request, String idempotencyKey)
        throws InterruptedException {

    RLock lock = redisson.getLock("idempotency:lock:" + idempotencyKey);

    // wait up to 5s to acquire; release automatically after 30s
    if (!lock.tryLock(5, 30, TimeUnit.SECONDS)) {
        throw new ConcurrentRequestException(idempotencyKey);
    }
    try {
        return processExactlyOnce(request);
    } finally {
        // the lease can expire while the work runs, and unlock()
        // throws if this thread no longer holds the lock
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

The lease time matters. If the holder dies mid-operation the lock must expire, or a crashed request blocks every retry of itself. See using locks in Java for the full pattern.

One limitation is worth stating plainly. Keys held in an in-memory store are not durable by default. If a duplicate charge would be unacceptable even after total cluster loss, the idempotency key needs to be written inside the same transactional boundary as the business record it protects. In-memory storage buys speed and automatic expiry; it does not by itself buy durability.

Idempotency in message-driven systems

Everything above is framed around HTTP, because that is where idempotency keys were popularised. The harder version of the problem lives in messaging, where a duplicate is not an edge case caused by a dropped connection — it is a documented guarantee of the transport.

Why brokers deliver at least once

A broker that removes a message the moment it hands it out loses that message whenever the consumer dies mid-work. That is at-most-once delivery, and almost nobody wants it. A broker that removes the message only once the consumer acknowledges cannot lose it — but if the consumer finishes the work and then crashes before acknowledging, the broker has no way to know, and hands the message to somebody else. That is at-least-once delivery, and it is what every serious message queue chooses, because a repeated message is a smaller problem than a missing one.

Redelivery is therefore not a defect to be configured away. Redis Streams park any entry left unacknowledged in the pending entries list, where another consumer takes it over with XAUTOCLAIM. A reliable queue redelivers once its visibility timeout expires. A dead letter queue exists precisely because redelivery can otherwise repeat forever. In each case the broker is working correctly and your handler will see the same message twice.

Exactly-once delivery and exactly-once processing

This is where marketing tends to outrun distributed systems theory. Exactly-once delivery over an unreliable network is not achievable by any broker: the acknowledgment can always be the thing that gets lost, and the sender cannot distinguish a message that never arrived from one that arrived and was never confirmed. It is the same uncertainty that makes HTTP retries necessary, moved one layer down.

What production systems actually provide — and what is worth having — is exactly-once processing: at-least-once delivery, combined with either a deduplication window at the broker or an idempotent consumer, so that each message takes effect once even though it may be delivered more than once. Some writers call the result effectively-once. Read any exactly-once claim as a claim about processing, and check which of the two mechanisms is doing the work.

What an idempotent consumer looks like

An idempotent consumer keeps its own record of the messages it has already handled and drops repeats. The message needs a stable identity to key that record on — a producer-assigned message ID, a business key such as an order number, or a hash of the payload. What it must not be is the broker's own delivery ID, which is regenerated on every redelivery and will never match.

The mechanics are the same atomic claim used for HTTP idempotency keys, so the same primitive applies. Where you only need to answer have I seen this before?, a TTL-backed set is the smallest thing that works:

RSetCache<String> seen = redisson.getSetCache("consumer:orders:seen");
String orderId = message.getOrderId();

// addIfAbsent returns false when the id is already present.
// One atomic call: the duplicate check and the claim cannot be split.
if (!seen.addIfAbsent(Duration.ofHours(24), orderId)) {
    ack(message);            // already processed — acknowledge and move on
    return;
}

try {
    process(message);
} catch (Exception e) {
    seen.remove(orderId);    // release the claim so a redelivery can retry
    throw e;                 // no acknowledgment: let the broker redeliver
}
ack(message);

Two details in that block are the whole point. Acknowledge the duplicate. A consumer that quietly returns without acknowledging leaves the message pending, and the broker delivers it again on the next visibility timeout — the handler is idempotent and the system still livelocks. Release the claim when the work fails. Claiming before processing is what makes the check atomic, but it means a failure after the claim has already consumed the right to retry; without the remove, the redelivery is discarded as a duplicate and the work never happens.

That still leaves one gap, and it is worth stating plainly: the catch covers an exception, not a consumer that dies mid-work. A process killed between the claim and the acknowledgment leaves the id sitting in the set until its TTL expires, and every redelivery inside that window is thrown away. Where that matters, claim with a short marker and extend it to the full window only on success — which is what the next pattern does.

It also helps where the consumer has to return something: a computed result, a downstream reference. Store the outcome under the same identity rather than in a separate structure, so the record of seen and the record of what happened cannot diverge:

RMapCache<String, String> handled = redisson.getMapCache("consumer:orders");

public Result handle(Message message) {
    String orderId = message.getOrderId();

    // fastPutIfAbsent is the same atomic claim, with a per-entry TTL.
    // Claim with a SHORT marker: if this consumer dies mid-work the
    // marker lapses in minutes rather than blocking retries for a day.
    if (!handled.fastPutIfAbsent(orderId, IN_PROGRESS, 5, TimeUnit.MINUTES)) {
        String stored = handled.get(orderId);
        if (stored == null || IN_PROGRESS.equals(stored)) {
            nack(message);                 // still running, or the marker
            return null;                   // lapsed — let it come back
        }
        ack(message);
        return deserialize(stored);        // duplicate: the original result
    }

    Result result = process(message);
    // replace the marker with the result, now under the full dedup window
    handled.put(orderId, serialize(result), 24, TimeUnit.HOURS);
    ack(message);
    return result;
}

The in-progress branch is not optional. A retry that arrives while the original is still running has to be told to come back rather than handed a marker to deserialise — the same requirement as the 409 in the HTTP case above.

RMapCache implements per-entry expiry with Lua scripts and a background eviction task, which costs a little more than a plain map. On Valkey 9.0+ or Redis 7.4+, RMapCacheNative delegates expiry to the server instead; a few of its operations need Redis 8.0+, already covered by Valkey 9.0.

Broker-side deduplication is not the same thing

Several systems offer deduplication at the broker, and it is genuinely useful — but it solves the producer half of the problem, not the consumer half. Redisson PRO's Reliable Queue deduplicates on a caller-supplied ID or on a payload hash for a configured interval:

queue.add(QueueAddArgs.messages(
    MessageArgs.payload(order)
               .deduplicationById(order.getId(), Duration.ofHours(24))));

Redis 8.6 adds a comparable facility to Streams, and its own documentation is careful about the wording: idempotent message processing, with at-most-once production. It tracks recent message IDs per producer for a bounded window — 100 seconds and 100 IDs per producer by default, whichever bound is reached first.

Deduplication is an add-time filter. Within the configured window, a message whose ID or payload hash has already been accepted is not enqueued a second time, which stops a retrying producer from creating two copies of one job. It does nothing about a message that was accepted once and then redelivered to a second consumer after a visibility timeout or a negative acknowledgment. That case is the consumer's to handle, and only an idempotent consumer handles it.

Sizing the window

Both mechanisms are bounded, and the bound is the whole design. A broker-side window has to outlast the producer's retry schedule, including any exponential backoff; a consumer-side window has to outlast the maximum time a message can remain in circulation, which is the visibility timeout multiplied by the delivery limit, plus whatever a dead letter queue replay might add later. Past that horizon the record is gone and a repeat is processed as new — silently, as always. Where a duplicate would be unacceptable at any distance, the deduplication record belongs in the same transactional boundary as the business write, which is the same conclusion the HTTP case reaches above, and the reason the outbox pattern exists.

Common idempotency mistakes

  • Deriving the key from the request body. Two legitimately identical requests — the same customer buying the same item twice — get collapsed into one. The key must be supplied by the client.
  • Checking and then writing. A non-atomic read followed by a write is a race that duplicates will eventually win.
  • Omitting a TTL. Keys accumulate indefinitely and the store grows until it is evicting live data.
  • Storing the key but not the response. Retries are correctly suppressed, but the client receives nothing useful and cannot tell success from failure.
  • Ignoring the in-progress state. A retry arriving while the original is still running needs a defined answer, usually a 409 telling the client to wait.
  • Assuming PUT is idempotent because the specification says so. It is idempotent only if your handler makes it so.
  • Treating broker-side deduplication as consumer protection. It filters duplicate enqueues within a window. It does not suppress redelivery of a message the broker already accepted.
  • Keying a consumer on the delivery ID. Redelivery generates a new one, so the check never matches. Key on a producer-assigned message ID, a business key, or a payload hash.
  • Dropping a duplicate without acknowledging it. The handler is idempotent, the message stays pending, and the broker redelivers it forever.

Frequently asked questions

What is the meaning of idempotency?

Idempotency is the property of an operation whose effect does not accumulate. Running it five times leaves the system exactly where running it once would have, so a caller who cannot tell whether their request landed is free to send it again. The word comes from algebra, where an idempotent function returns its own output unchanged when reapplied.

What is idempotency in an API?

In an API, idempotency means a client can safely retry a request without risking a duplicate effect. It matters because a dropped connection leaves the caller unable to tell whether the request never arrived or succeeded with a lost response. HTTP semantics expect GET, PUT and DELETE to be idempotent; POST is not, which is why APIs that create resources or take payments accept an Idempotency-Key header.

What is a real-life example of idempotency?

The lift call button is the standard illustration: however many times you jab it, one lift arrives. A toggle light switch is the opposite, since each press reverses whatever came before. In code, the equivalent contrast is between assigning a balance a fixed value and incrementing it — and the place that distinction costs real money is a payment API charging a customer's card.

What is the difference between idempotence and idempotency?

None. They are alternative nouns for a single property, and no specification treats them differently. Mathematicians tend to write idempotence; software and API documentation settled on idempotency, which is why the HTTP header is spelled Idempotency-Key. Idempotent is simply the adjective form, applied to the operation rather than to the property.

Is POST idempotent?

No. HTTP semantics define POST as neither safe nor idempotent, because each call is intended to create a resource or trigger a new effect. APIs make POST safe to retry by adding an idempotency key: the client sends a unique value for each logical operation, and the server returns the stored result rather than repeating the work when it sees that key again.

Is exactly-once delivery possible?

No, not over an unreliable network. Any acknowledgment can itself be lost, which leaves the sender unable to tell a message that never arrived from one that arrived and was never confirmed — so the only safe move is to send again. What real systems provide is exactly-once processing: at-least-once delivery combined with deduplication at the broker or an idempotent consumer, so a message that is delivered twice still takes effect once. Treat any exactly-once claim as a claim about processing and ask which mechanism is providing it.

What is the difference between idempotency and exactly-once delivery?

Exactly-once delivery is a promise about the transport: the broker undertakes to hand each message over precisely once. Idempotency is a property of your handler: running it twice has the same effect as running it once. The first is not achievable and the second is, which is why idempotency is the mechanism that actually delivers what people want from exactly-once. Deduplication at the broker complements it by filtering duplicate sends, but it cannot cover redelivery of a message the broker already accepted.

How long should an idempotency key live?

Long enough to outlast every retry that could still arrive. Twenty-four hours is the common default and is what Stripe uses, which comfortably covers a client retrying with exponential backoff. For a message consumer the figure to beat is the visibility timeout multiplied by the delivery limit, plus any dead letter queue replay. Once the key expires the protection ends and a repeat is processed as a new request, with no warning — so if a duplicate would be unacceptable at any distance in time, record the key inside the same transaction as the business write rather than relying on a TTL.

Similar terms

Redisson provides the distributed objects that idempotency depends on — atomic buckets, locks, TTL-backed maps and rate limiters — through one API across Valkey and Redis. Start a free trial.