Idempotency

Concept

The property that makes a request arriving two or three times land exactly as it would have landed once, so a retry never produces duplicate work.

Why it exists

Picture a checkout button that spins for three seconds with nothing happening. The user decides the tap didn't register, presses again, and the card gets charged twice. In Pagedeck's first year, half the billing tickets that reached Sofia looked like that. Two charges, one plan, and from the customer's side it read less like a glitch than like a company helping itself to a card.

The first fix was a front-end one. The button turned gray the instant it was pressed so it couldn't be tapped twice. Tickets dropped, but they didn't stop, because customers kept writing in who were certain they had pressed once.

The cause turned out to be the network rather than the person. A request can reach the server, finish processing, and then lose only the response on the way back. From the sender's side that outcome is indistinguishable from a request that never arrived at all — both look like silence. So most networking code is written to send again when nothing comes back, and the app quietly repeats the request without the user touching anything.

That leaves two paths. Remove the retry, or build a server that doesn't mind one. The first path fails: without retries, every request whose response got dropped stays failed forever, and instead of customers charged twice you get customers not charged at all. Idempotency is the second path. However many copies of a request arrive, only the first does real work and the rest receive the first one's answer.

How it works

Each request carries a unique key, and the server checks whether it has already handled that key before doing anything else. The key is called an idempotency key.

There are five steps.

  1. The sender creates one key per attempt. It generates a random string when checkout begins and reuses that same string on every retry rather than minting a fresh one.
  2. The server writes the key down before processing. Store it in the database — the system that holds data and hands it back — in a column that refuses duplicates, and a second write of the same key fails at the storage layer instead of somewhere deep in the logic.
  3. If the key already exists, the server skips the real work and returns the response it sent the first time. No second charge, and the user still sees a success screen.
  4. If the key is new, the server does the work and saves the result alongside the key. Processing and saving go inside one transaction — a group of operations that either all succeed or all roll back — so you never end up with a charge that has no key recorded against it.
  5. Keys expire. Twenty-four hours is typical. Keeping them forever only grows storage, and retries finish within minutes at the outside.

Some operations are already idempotent without a key. The dividing line is whether the request sets a value or moves one. "Set this account's plan to Pro" gives the same result run a hundred times. "Subtract 10 from this account's remaining credits" gives a different result each run. Deletion falls on the safe side too: deleting something already gone leaves it gone.

This is why the shape of an API — the agreed interface two programs use to talk — gets decided early. An endpoint that states what a value should become is safe without keys. An endpoint that says how far to move a value needs them.

Where it shows up

Anywhere retries can happen, though four places do the real damage when they're missed.

  1. Payments and orders — the case above. One slip turns into a refund and an apology.
  2. Webhooks — notifications a system pushes to another server when an event occurs, such as a payment processor telling Pagedeck that a charge cleared. Senders retry until they get an acknowledgement, so the same notification arriving three or four times is routine. The receiver has to record the event id and ignore repeats.
  3. Message queues — the mechanism that lines work up to be handled later. Most queues guarantee at-least-once delivery, not exactly-once, because an item being processed when a worker dies goes back into the line.
  4. Background jobs — work split off from the response and run behind it. Automatic retry of failed jobs usually ships with the job runner, which is how one welcome email turns into three.

The term that travels alongside idempotency is concurrency, the practice of running work in overlapping fashion. Overlap means two requests carrying the same key can land a tenth of a second apart. If both decide the key is absent and both proceed to charge, the key bought nothing. That failure — where the outcome depends on which operation happened to go first — is a race condition. The fix is to let the storage layer check and record the key in a single step rather than reading, deciding, and writing in application code.

The same property applied to a whole data flow instead of a single request is an idempotent pipeline: rerunning yesterday's aggregation without doubling yesterday's revenue.

How it sounds at work

"Is this endpoint idempotent? I'm about to put automatic retries on it."

Checking whether it's safe for the same request to go through twice.

"The webhook came in twice and the notification went out twice. We should filter on the event id."

Reporting that the receiving side has no idempotency handling.

"Put an idempotency key on the checkout request. It has to be generated on the client, not the server."

Specifying where the key is created. A server-generated key is new on every request, so nothing gets filtered.

At Pagedeck the phrase comes up most often in code review. When Theo puts up a change that touches billing or notifications, the first thing a reviewer looks for is where the key is generated and where it gets recorded. A change without an answer to that doesn't merge, however well the feature works.

What people get wrong

The most common misreading is that idempotency blocks duplicate requests. The name sounds like duplicate prevention, which makes the jump easy. But it blocks nothing. It makes duplicates harmless. Blocking requests is the front-end fix of graying out a button, and all that catches is impatient tapping. Treat the two as the same thing and you ship the button change, call it solved, and keep collecting double-charge tickets from people on bad connections — tickets nobody can reproduce.

The other one is assuming it only matters where money is involved. Payments are where the damage is visible, so the assumption is natural. Retries, though, exist in every path where two systems talk. Three invitation emails, a notification that fires three times, a daily usage figure counted twice — same failure wearing different clothes. Guard checkout, shrug at the rest, and users form their opinion of the product from the notifications and the inbox instead.

Where to go next

  • Backend

    see which layer of the server this property has to hold in

  • Concurrency

    the other force that shakes results as often as retries do

Last updated 2026-09-16