Post

Idempotency: Why a Repeated Click Can Cost a Business Money

Idempotency: Why a Repeated Click Can Cost a Business Money

In short: idempotency isn’t about rejecting duplicate requests — it’s about making sure the same intent, delivered more than once, never triggers the side effect a second time. A retry after a dropped connection and a brand-new order look identical on the wire, but only the server can tell them apart — and only if it captures intent, not just input.

Idempotency of an operation means that executing it again with the same input produces the same result as executing it once. Whether an operation runs once or ten times in a row, the resulting system state is identical.

Example:

Stuffing ten apples into your pocket — not idempotent, the apples are different. Stuffing the same apple into your pocket ten times — idempotent, it’s the same apple.

At first glance this sounds trivial. An apple is an apple. How do you tell one apart from the other, and why does it matter?

Pizza, dieting, and a dropped connection

Remember ordering pizza after swearing off late-night binges. Fine, I’ll get one, to stay within my calorie limit. Click — order confirmed. Two minutes of second-guessing later — damn, I’ll still be hungry. I place a new order for another pizza — click. That’s two conscious decisions, two separate orders. And that’s fine.

Now the same situation with people who have a bit more willpower. One pizza. Payment initiated. Connection drops. The outcome is unknown.

Think about what we do next. Right — we immediately hit back and try to repeat that exact same purchase operation.

There’s another path too. Not understanding how the process works under the hood, we place a brand-new order instead.

If the payment actually failed but the server responds with “operation processed,” repeating the operation may leave the customer without their order — and the business without the revenue. Placing a new operation instead may leave the customer with twice the pizza they wanted, which carries its own reputational risk.

In practice, the difference between “the same operation” and “a new operation” isn’t obvious even to many developers. Let alone to end users.

Where the difference lies

It lies in making a decision based on sufficient information for an informed choice. And that information has to exist on both sides — the customer’s and the business’s.

For the customer that’s push notifications, an order history page, and so on. For the business — it’s a mechanism to capture the customer’s intent and distinguish a repeat of that intent from a brand-new one.

What follows is how this works under the hood. If you just wanted the idea, you can stop here. If you’re curious how it’s implemented on the backend, let’s go deeper.

Idempotency isn’t only about POST

Idempotency isn’t always expressed as an explicit operation key — it can also be baked directly into the business logic itself, with no UUID header in sight.

Take CRUD operations.

Read is idempotent: reading a resource once or a hundred times has no effect on system state.

Delete is idempotent too: delete an order and it’s gone. Deleting the same resource again changes nothing — it wasn’t there before and it isn’t there now. It’s worth distinguishing resource state from HTTP response here: a second DELETE will usually return 404 instead of 200/204, because the resource no longer exists — but that’s a protocol-level response, not a violation of idempotency. The second call still produces no side effect.

Update — this is where things get less clear-cut, and where it’s easy to get it wrong.

If an update replaces the entire state (“set status = cancelled”, “set balance = 100”), it’s idempotent — repeat it as many times as you like, the result is the same. But if it’s a change relative to the current state (“add 10 to the balance”, “increment the counter by 1”), it’s not — do it twice and you’ve added twice.

That’s exactly why, per the HTTP spec, PUT (a full resource replacement) is considered idempotent, while PATCH (a partial update) is not, and depends on the specific semantics involved.

An idempotency key is not just a unique field

This is somewhere I used to mix up two different things myself, and it’s worth untangling right away so you don’t do the same.

An entity identifier and an operation’s idempotency key are not the same thing, even though they sometimes coincide.

A user’s email is a natural key of the Customer entity. It uniquely identifies who I’m dealing with. But an Idempotency-Key: 7f3a... header on POST /payments isn’t a field of the Payment entity at all. It’s the key of a specific client intent to perform an operation, generated by the client (or the client’s device) before that operation exists anywhere. The Payment doesn’t exist yet, but the key already does.

Sometimes these two axes genuinely do coincide — for instance, POST /customers with a unique email can serve both as the entity identifier and as a way to prevent duplicate customer records. But that’s a special case, not the rule. Conflating them means designing an API that breaks precisely where idempotency was supposed to save it.

The operation key is typically set by the client itself, or generated by the client application before the first attempt to perform the operation — before the request is even sent — most often as a UUID. It’s important that the same key is reused for all retries within a single intent, rather than regenerated on every retry. A natural key like an email or phone number is a separate matter entirely — entity identification, not operation idempotency.

What the server should do — not just the database

The point of idempotency isn’t “reject the duplicate” — it’s “redelivering the same intent must not trigger the side effect a second time.” Formally that’s f(f(x)) = f(x), but it’s more practical for an engineer to think in terms of the side effect: ABC → charge $100 must not turn into ABC → charge another $100 on retry.

So the server needs to:

On the first request with a given key — perform the operation and store the result together with the key. On a repeat with the same key — skip re-execution and return the already-stored result. This is exactly what rescues the dropped-connection scenario: you hit “retry” and get confirmation of the same order, instead of a new one being created.

The database is the ultimate source of guarantee here, but not necessarily via a bare UNIQUE constraint on the entity itself. Typically you set up a separate record — something like idempotency_key, request_hash, status, response — and put UNIQUE (idempotency_key) on that. UNIQUE guards against inserting the same key twice, but you still need to think through concurrent requests arriving nearly simultaneously — for example, via an intermediate in_progress state recorded before processing begins.

A cache in front of the database can act as a fail-fast prefilter, so repeated requests don’t hammer the database.

When a human is the one responsible for idempotency

I recently ordered plăcinte from “La Plăcintă” in Chișinău, Moldova. The website stated upfront: expect a call from a manager to confirm the order quantity. Sure enough, five minutes later (impressively fast!) a manager called to ask: did you really mean to order five pizzas?

Essentially, this is a manual compensation for the absence of automatic idempotency on the backend — a human took on the role of deduplicator. It works, and might even be justified for a small volume of orders. But it doesn’t scale, and gets expensive as the business grows.

As you can see, the idempotency problem can be solved in more than one way. What matters is not confusing an entity identifier with an operation key, and remembering that the goal isn’t “reject the repeat” but “return the same result.”

How have you solved the duplicate-request problem in your own systems — a header key, a database constraint, or maybe a manager’s phone call too? Share in the comments, curious to compare approaches.


Article history — LinkedIn feed (as of publication)

This post is licensed under CC BY 4.0 by the author.