You click "Submit Payment." The loading spinner spins. And spins. Then your browser drops the connection, leaving you wondering if the payment went through.

If you click the button again, a poorly designed API will charge your card a second time. A well-designed API will recognize the duplicate request, process the payment exactly once, and return the success message both times.

That is idempotency. A system is idempotent if doing the same thing multiple times gives the same result as doing it once.

The problem with retries

The internet is not perfect. Connections drop, servers timeout, and phones lose signal.

When you send a request and get no response, you don't know what happened. The request might have failed to reach the server, or the server might have finished the job but the success message got lost on its way back to you.

Sequence diagram showing a network drop causing a double payment
The client assumes failure and retries, causing the backend to process the payment twice.

Because of this, the app has to try again. But if the app tries a payment again, the user might get charged twice, or the database might save two records.

The HTTP baseline

HTTP rules say which methods are safe to repeat:

  • GET, HEAD, OPTIONS: Read-only. You can fetch a user profile 100 times, and the server's data doesn't change.
  • PUT, DELETE: State-changing, but naturally safe to repeat. Deleting a file that is already deleted leaves the file deleted. Updating a username to "john_doe" ten times leaves the username as "john_doe".
  • POST: Not safe to repeat. Sending a POST request to /checkouts creates a new checkout every single time.

Making POST requests safe to repeat takes a bit of extra work.

The Idempotency-Key header

The standard fix makes the client (like a mobile app or browser) responsible for proving the request is unique. The client creates a unique ID and sends it in the HTTP header, usually called Idempotency-Key.

A UUID is perfect for this. The client creates the UUID, attaches it to the request, and keeps sending that exact same ID if it needs to retry. If the user starts a brand new payment, it creates a new ID.

POST /v1/payments HTTP/1.1
Host: api.example.com
Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000
Content-Type: application/json

{
  "user_id": 987,
  "amount": 5000,
  "currency": "USD"
}

How it works behind the scenes

When your server gets a request with an Idempotency-Key, it needs a shared place to track it. You can't just save it in the server's memory, because a retry might be sent to a completely different server behind the load balancer.

Redis is great for this because it is fast and supports commands like SETNX (set if it doesn't exist). This stops two requests with the same key from running at the same time. A database like PostgreSQL works too, if you use a unique column.

The server follows this flow:

Sequence diagram of a backend handling idempotency with Redis
The backend uses Redis to manage the idempotency lock. A retry fetches the cached response instead of doing the work again.
  1. Try to claim the key in the database.
  2. If claimed successfully (a new request), save the key as IN_PROGRESS. Do the actual work (like calling Stripe). Once finished, update the key with the final result and the HTTP response, marking it COMPLETED, and return the response to the client.
  3. If the key already exists and is IN_PROGRESS, the client is retrying while the first request is still running. Return an error to tell the client to wait.
  4. If the key already exists and is COMPLETED, skip the work. Fetch the saved HTTP response from the database and return it to the client.

A simplified code flow in Rust using Redis:

// 1. Try to claim the key
let claimed = redis.set_nx(idempotency_key, "IN_PROGRESS");

if !claimed {
    let status = redis.get(idempotency_key);
    if status == "IN_PROGRESS" {
        return HttpResponse::Conflict().body("Request already in progress");
    } else {
        // Return the cached response
        return HttpResponse::Ok().json(status); 
    }
}

// 2. Do the actual work (only happens once)
let payment_result = process_payment(amount);

// 3. Save the result and return
redis.set(idempotency_key, serialize(payment_result));
return HttpResponse::Ok().json(payment_result);

Handling edge cases

If the server crashes right after saving IN_PROGRESS, but before doing the payment, the key gets stuck. If the client tries again, it will endlessly get an error because the lock is never removed.

To fix this, the IN_PROGRESS state needs a short Time-To-Live (TTL). If your longest request takes 5 seconds, set the TTL to 10 seconds. If a request is stuck in IN_PROGRESS longer than that, assume the server crashed. Redis will automatically drop the key, allowing the next retry to claim it and try again.

Keys shouldn't be stored in the COMPLETED state forever either. Memory is limited. Stripe keeps idempotency keys for 24 hours. After that, they expire and the same key can theoretically be used again. Clients should always generate fresh UUIDs for new payments anyway.

Idempotency turns unpredictable internet problems into safe, predictable actions. It stops the user from worrying about double-clicking a frozen button, and lets your servers handle the mess instead.