System DesignPublished 2/18/2026

Idempotency: Why Retrying an API Call Should Not Break Things

Starting from a real-world network retry scenario, this post breaks down what idempotency actually means, why distributed systems cannot avoid it, and how to design APIs that survive being called more than once using an idempotency key.

You've probably had this happen: you hit checkout on a shopping site, the page spins for five seconds with nothing happening, so you get impatient and click again — and end up with two identical orders and two charges on your card. That's not bad luck. It happens constantly across the internet, and it comes down to one core concept: idempotency. This post walks through what idempotency actually means, why distributed systems can't avoid it, and how to design APIs that survive being called twice.

Picture This: A Checkout Button That Hangs

Say we're building a checkout flow. The user hits "Pay," the frontend sends a request to the backend, the backend charges the card and creates the order, then sends back a success response. Simple enough — until you look at the gap between "request sent" and "response received."

If the network stalls during that window, the frontend just sees nothing. No response. It has no way of knowing whether the backend actually processed the request. Maybe the backend never got it. Maybe it charged the card successfully and the response just got lost on the way back. The only reasonable move for the frontend is to retry: send the exact same request again.

Here's the problem: if the first request actually succeeded, retrying means charging the card twice and creating two orders. That's exactly the problem idempotency exists to solve.

What Idempotency Actually Means

The core idea is simple: running an operation once should produce the same end result as running it any number of times.

A good analogy is an elevator button. Press it once, the elevator comes. Get impatient and mash the same button five times, and the elevator still only makes one trip — it doesn't show up five times. The logic behind the button is "is someone waiting," not "how many times was it pressed." That's idempotent behavior.

Translate that to API design: an idempotent operation means calling it once and calling it ten times leave the system in exactly the same state. Repeated calls don't stack up side effects that shouldn't exist — like charging a card twice or creating duplicate orders.

Why Distributed Systems Can't Escape This

On a single machine, inside a single process, you rarely have to worry about this — there's no uncertainty about whether a call landed. But the moment a system crosses a network boundary — frontend calling a backend API, service calling service, a consumer reading off a message queue — you're going to run into timeouts, dropped packets, and services that briefly go down.

That puts the caller in the same bind every time: no response doesn't mean the other side failed, but not retrying can leave the user stuck halfway through. Pretty much every retry mechanism exists to make an unreliable network look reliable from the outside. And retries are only safe if the operation being retried is idempotent — otherwise, the more you retry, the worse things get.

Message queues are another common place this shows up. Most queues guarantee at-least-once delivery to avoid dropping messages, which means a consumer can end up receiving the same message more than once. If the consumer's processing logic isn't idempotent, the same order can get processed twice.

Which HTTP Methods Are Idempotent by Default

The HTTP spec actually defines idempotency for each method, so we can just use that as a design reference:

  • GET: Pure read. Calling it any number of times never changes server state — idempotent by nature.
  • PUT: Replaces a resource wholesale, like overwriting a user's entire profile with new data. Call it once or ten times, the resource ends up in the same state — idempotent.
  • DELETE: Deletes a resource. The first call removes it; every call after that leaves it in the same "doesn't exist" state. Even though the status code might change from 200 to 404, the resource's final state doesn't change again — so this counts as idempotent too.
  • POST: Typically used to create a resource, like a new order. Call it once, you get one order; call it twice, you get two. Every call stacks a new side effect, so POST is not idempotent by default.

Which means checkout, order creation, sending notifications — basically anything that "creates" something — is usually built on POST, and that's exactly where retries cause the most damage.

Making POST Idempotent: The Idempotency Key Pattern

Since POST isn't idempotent by default but still needs to survive retries safely, the common industry solution is the idempotency key pattern — it's what payment APIs like Stripe use.

Here's how it works: before sending a request, the caller generates a unique identifier (usually a UUID) and attaches it as a custom header, like Idempotency-Key. When the server receives the request, instead of processing it immediately, it first checks whether it's seen that key before:

  • If it's the first time seeing this key, the server runs the operation normally and stores the key alongside the result.
  • If the key has already been processed, the server just returns the stored result instead of running the operation again.

That way, even if the frontend retries because it never got a response, as long as the retry carries the same idempotency key, the backend recognizes it as the same request and avoids charging the card twice.

js
// Simplified idempotency key middleware
async function handleCheckout(req, res) {
  const key = req.headers['idempotency-key']
 
  // check whether this key has already been processed
  const existing = await db.idempotencyRecords.findOne({ key })
  if (existing) {
    return res.status(existing.statusCode).json(existing.body)
    // return the previous result instead of charging again
  }
 
  const result = await chargeAndCreateOrder(req.body)
 
  await db.idempotencyRecords.insert({
    key,
    statusCode: 200,
    body: result,
  })
 
  return res.status(200).json(result)
}

What to Watch for at the Database Level

There's a hidden race condition in the code above: if the same key gets sent twice in rapid succession, both requests can hit the "check if it's been processed" step before either one has written a record — so both proceed, and you're back to charging twice.

In practice, you'd put a unique constraint on the idempotency key column and use INSERT ... ON CONFLICT (or your database's equivalent upsert syntax) inside a transaction, so "write the key" and "check for duplicates" become a single atomic operation — the database itself guarantees two records with the same key can never both succeed. When the second request hits the unique constraint and fails, it just falls back to looking up and returning the existing result.

Idempotency key records also shouldn't live forever. You'd typically set a retention window — 24 hours, say — and clean them up after that, otherwise storage just keeps growing with traffic.

Idempotency in Message Queues

As mentioned earlier, at-least-once delivery means a consumer can receive the same message more than once. In that setup, idempotency becomes the consumer's responsibility. The common approach is to use the message's own unique identifier (a message ID, or a business-level order number) as the key: check whether that ID has already been processed before handling it, then record it once you're done. The logic is nearly identical to the idempotency key pattern above — you're just swapping the HTTP header for message metadata.

Common Misconceptions

Idempotent doesn't mean side-effect-free. PUT is idempotent, but it still writes to the database — that's a side effect. It just means the end result stays the same no matter how many times you call it; it doesn't accumulate. Idempotency is about the final result being consistent across repeated calls, not about having zero impact.

Idempotent doesn't mean synchronous or instantly consistent. Some systems process things asynchronously, so after a retry it might take a moment before the state actually updates. Idempotency guarantees eventual consistency, not that every call returns the identical result immediately.

Not every duplicate request should reuse the same idempotency key. If a user genuinely wants to buy the same item again — not retrying because of a network hiccup — that should get a brand-new key and be treated as a new operation. An idempotency key protects against the same intent being submitted more than once; it's not there to stop users from placing a second, legitimate order.

Wrapping Up

Idempotency means an operation produces the same end result whether it runs once or gets retried multiple times — and that's close to a required course in distributed systems, since anything that touches a network will eventually run into uncertainty about whether a request landed and whether to retry it. GET, PUT, and DELETE are idempotent by nature; create-type operations like POST need an idempotency key paired with a database-level unique constraint to get that same guarantee.