SSourav Saha
HomeExperienceSoftware DesignSystem DesignLearningBooksToolsContact
SSourav Saha

Building scalable backend systems, distributed infrastructure, and cloud-native applications.

Navigation

  • Home
  • Experience
  • Software Design
  • System Design
  • Learning

More

  • Books
  • Tools
  • Contact

Connect

  • LinkedIn
  • Email

© 2026 Sourav Saha. All rights reserved.

Built with using Next.js

System DesignIdempotency
System Designintermediate

Idempotency

Learn how to safely retry failed operations without causing duplicate charges or data corruption. Understand Idempotency Keys, exactly-once semantics, and build an idempotent API in Python.

April 5, 202412 min read
idempotencyapi-designreliabilitydistributed-systemspaymentsbuilding-blocktier-4

Metadata

FieldValue
Slugidempotency
DifficultyIntermediate
Estimated Reading Time12 min
Estimated Coding Time20 min
Tier4 — Reliability & Fault Tolerance
Implementation LanguagePython
SEO DescriptionLearn what Idempotency is in system design. Understand how to prevent duplicate payments, use Idempotency Keys, and build a safe, retryable API in Python.

1. Overview

What problem does it solve?

In distributed systems, networks are flaky. Imagine a user clicks "Checkout" on an e-commerce site. The app sends a POST /charge request to the backend. The backend successfully charges the user's credit card for $100. But right as the backend sends the 200 OK response, the Wi-Fi drops.

The user's app shows an error: "Network timeout." The user clicks "Checkout" again. The backend receives another POST /charge and charges another $100. The user was double-charged.

An operation is Idempotent if applying it multiple times has the exact same effect as applying it once.

What breaks without it?

  • Double Charges: The classic payment processing nightmare.
  • Duplicate Data: Submitting a form twice creates two identical users in the database.
  • Retry Storms: If you implement Retry & Backoff on an API that is not idempotent, you will systematically corrupt your database every time the network lags.

2. Motivation

In mathematics, an idempotent operation is one where f(f(x)) = f(x). For example, abs() is idempotent because abs(abs(-5)) is the same as abs(-5). But x + 1 is not.

In HTTP, certain methods are defined as naturally idempotent by the specification:

  • GET, PUT, DELETE are idempotent. (Deleting a user twice still results in the user being deleted).
  • POST is NOT idempotent. (Posting a user twice creates two users).

As systems moved to Microservices and asynchronous Message Queues, the guarantee of "Exactly-Once Delivery" became nearly impossible to achieve at the network layer. The industry shifted to "At-Least-Once Delivery" combined with Idempotent Consumers. (Stripe popularized the Idempotency-Key HTTP header for APIs).


3. Real-World Usage

SystemUse Case
Stripe APIRequires an Idempotency-Key header on all POST requests to ensure no one is double-charged during network retries.
Message Queue ConsumersWorkers processing messages from Kafka/SQS must be idempotent in case the queue resends a message.
Infrastructure as Code (Terraform/Ansible)Running terraform apply 10 times results in the exact same infrastructure state as running it once.

4. Prerequisites

ConceptBlock
HTTP Methods002 REST API Design
Message Retries024 Message Queues

5. Visual Explanation

The Problem: The Two Generals' Problem

When a network times out, the client cannot know if the request failed before reaching the server, or if the response failed after the server processed it.

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#ffcc00', 'edgeLabelBackground':'#ffffff'}}}%%
sequenceDiagram
    participant Client
    participant Server

    Client->>Server: POST /charge ($100)
    Note over Server: Charges Credit Card
    Server--xClient: 200 OK (Packet Lost in Transit)
    
    Note over Client: Timeout! Did it work?<br/>I have to retry!
    
    Client->>Server: POST /charge ($100)
    Note over Server: Charges Credit Card AGAIN
    Server-->>Client: 200 OK

The Solution: The Idempotency Key

sequenceDiagram
    participant Client
    participant Server
    participant Cache as Redis (Idempotency Store)

    Note over Client: Generate UUID: abc-123
    Client->>Server: POST /charge (Key: abc-123)
    
    Server->>Cache: Exists('abc-123')? -> NO
    Note over Server: Charges Credit Card
    Server->>Cache: Save('abc-123', Response: 200 OK)
    
    Server--xClient: 200 OK (Packet Lost!)
    
    Note over Client: Timeout! Retrying exact same request.
    
    Client->>Server: POST /charge (Key: abc-123)
    Server->>Cache: Exists('abc-123')? -> YES!
    
    Note over Server: Skip charging. Fetch old response.
    Server-->>Client: 200 OK (Cached Response)

6. Internal Working

The Idempotency Key

The client generates a unique identifier (usually a UUID v4) for the specific operation it wants to perform. This is passed in the header: Idempotency-Key: <uuid>.

  1. Check: Server receives request. Checks if the key exists in its Idempotency Store (e.g., Redis or a Postgres table).
  2. Hit: If the key exists, the server immediately returns the cached HTTP response from the previous successful attempt. No business logic is run.
  3. Miss: If the key does not exist, the server inserts the key with a status of IN_PROGRESS.
  4. Execute: The server runs the business logic (e.g., charges the card).
  5. Save: The server updates the key's value in the store with the final HTTP response payload and status code.
  6. Return: The server returns the response to the client.

Concurrent Retries

What if the client clicks the button twice very fast, sending two requests with the same key simultaneously? Because of step 3 (IN_PROGRESS), the second request will see that the key is currently being processed. It should either block and wait, or instantly return a 409 Conflict (Client should retry).


7. Implementation

Why Python? Python allows us to mock a web server and a Redis cache easily, clearly demonstrating the storage and retrieval of the idempotency token.

"""
030 - Idempotency
A simulation of a Payment API that prevents double-charges
using an Idempotency Key stored in a mock Redis cache.
"""
import uuid
import time
from enum import Enum

class Status(Enum):
    IN_PROGRESS = "IN_PROGRESS"
    COMPLETED = "COMPLETED"

# Mock Database & Cache
bank_balances = {"user_1": 500} # User starts with $500
idempotency_store = {}          # Mocks Redis: Key -> (Status, Response)


def charge_credit_card(user_id, amount):
    """The core business logic that we NEVER want to run twice."""
    print(f"  [Stripe] Charging {user_id} ${amount}...")
    time.sleep(1) # Simulate network call to payment processor
    bank_balances[user_id] -= amount
    return {"status": "success", "transaction_id": "txn_888", "amount": amount}


def api_post_charge(user_id, amount, idempotency_key=None):
    """The API Endpoint Handler."""
    
    if not idempotency_key:
        return 400, {"error": "Idempotency-Key header is required"}

    # 1. Check the Idempotency Store
    if idempotency_key in idempotency_store:
        status, cached_response = idempotency_store[idempotency_key]
        
        if status == Status.IN_PROGRESS:
            print(f"[API] ⚠️ Concurrent request detected for {idempotency_key}")
            return 409, {"error": "Request already in progress. Please wait."}
            
        if status == Status.COMPLETED:
            print(f"[API] ♻️  Idempotency Hit! Returning cached response.")
            return 200, cached_response

    # 2. Mark as IN_PROGRESS (Atomic lock in real life)
    idempotency_store[idempotency_key] = (Status.IN_PROGRESS, None)

    try:
        # 3. Execute fragile business logic
        result = charge_credit_card(user_id, amount)
        
        # 4. Save result and mark COMPLETED
        idempotency_store[idempotency_key] = (Status.COMPLETED, result)
        
        print("[API] ✅ Payment successful. Result saved.")
        return 200, result
        
    except Exception as e:
        # On actual error, remove the key so they can try again
        del idempotency_store[idempotency_key]
        return 500, {"error": "Internal Server Error"}


# ── Test Harness ──

if __name__ == "__main__":
    user = "user_1"
    amount = 100
    
    print(f"Initial Balance: ${bank_balances[user]}\n")

    # Client generates a unique key for THIS specific checkout attempt
    idem_key = str(uuid.uuid4())

    print("--- 1. Client sends initial request ---")
    status, resp = api_post_charge(user, amount, idempotency_key=idem_key)
    print(f"Response: {status} {resp}")
    print(f"Current Balance: ${bank_balances[user]}\n")

    print("--- 2. Network drops! Client retries with EXACT SAME KEY ---")
    # Because it's a retry of the same action, the client uses the same key
    status, resp = api_post_charge(user, amount, idempotency_key=idem_key)
    print(f"Response: {status} {resp}")
    print(f"Current Balance: ${bank_balances[user]}\n")

    print("--- 3. Client buys a second item (Generates NEW KEY) ---")
    # It's a new checkout, so the client generates a new key
    new_key = str(uuid.uuid4())
    status, resp = api_post_charge(user, amount, idempotency_key=new_key)
    print(f"Response: {status} {resp}")
    print(f"Current Balance: ${bank_balances[user]}\n")

Sample Output

Initial Balance: $500

--- 1. Client sends initial request ---
  [Stripe] Charging user_1 $100...
[API] ✅ Payment successful. Result saved.
Response: 200 {'status': 'success', 'transaction_id': 'txn_888', 'amount': 100}
Current Balance: $400

--- 2. Network drops! Client retries with EXACT SAME KEY ---
[API] ♻️  Idempotency Hit! Returning cached response.
Response: 200 {'status': 'success', 'transaction_id': 'txn_888', 'amount': 100}
Current Balance: $400

--- 3. Client buys a second item (Generates NEW KEY) ---
  [Stripe] Charging user_1 $100...
[API] ✅ Payment successful. Result saved.
Response: 200 {'status': 'success', 'transaction_id': 'txn_888', 'amount': 100}
Current Balance: $300

Notice in Step 2, the [Stripe] log doesn't print, and the balance doesn't drop. The cache caught it!


8. Complexity

MetricDetails
Time ComplexityO(1) for Redis cache lookup. Adds ~1-2ms to the API response time.
Space ComplexityO(N) where N is the number of requests kept in the cache. To prevent OOM, keys usually have a TTL (Time To Live) of 24 hours.
Database ContentionIf using SQL for idempotency, it requires an extra INSERT and UPDATE per request, which can slow down high-throughput systems.

9. Trade-offs

StrategyProsCons
Natural IdempotencyFree. (e.g., UPDATE users SET status='active'). You can run it 100 times safely.Impossible for actions that create resources or mutate state relative to current state (e.g., balance = balance - 100).
Idempotency KeysSafest. Solves the Two Generals problem on flaky networks.Requires client cooperation (they must generate and store the UUID). Requires server infrastructure (Redis).

10. Production Evolution

FeatureThis ImplementationProduction
AtomicityPython DictionaryChecking IN_PROGRESS and setting it must be an atomic operation (e.g., Redis SETNX or Postgres INSERT ... ON CONFLICT).
TTL (Expiration)InfiniteKeys expire after 24 hours to save memory. A retry after 24 hours is treated as a new request.
Payload HashingTrusts the keyThe server hashes the request body and stores it with the key. If the client sends the same key but a different body (e.g., amount = $200), the server throws a 400 Bad Request to prevent misuse.

11. Common Bugs

BugWhat happensFix
Client changes the key on retryMobile app retries the request but generates a new UUID on every loop. User gets charged 5 times.The client must store the generated UUID locally and reuse it for all retries of that specific user action.
Race ConditionsTwo requests with the same key arrive exactly at the same millisecond. Both pass the if key in store: check. Both charge the card.Use Atomic Locks (e.g. Database Unique Constraints or Redis SETNX) for the insertion step.
Idempotency on GETAdding an idempotency key to a GET request to cache it.Don't reinvent the wheel. Use standard HTTP Cache-Control headers or a CDN for GET requests. Idempotency Keys are for mutations (POST/PATCH).

12. Interview Questions

  1. What is an idempotent operation? Hint: An operation that produces the same result if executed once or multiple times.

  2. Why is an Idempotency Key required for payment APIs? Hint: The Two Generals problem. If the network drops, the client doesn't know if the server charged them. They must retry. The key tells the server "I am retrying checkout attempt A, do not charge them twice."

  3. How do you prevent a race condition where a user double-clicks the submit button rapidly? Hint: The idempotency store must use atomic locks (like Redis SETNX or SQL unique constraints). The second request hits the lock and returns a 409 Conflict.

  4. Is PUT idempotent? Is POST idempotent? Hint: PUT (replace whole resource) is idempotent by definition. POST (create new resource) is not.


13. Used By (Downstream Blocks)

  • 029 Retry & Exponential Backoff — You can only safely enable automatic retries if the downstream API is idempotent.
  • 024 Message Queues — Pub/Sub guarantees at-least-once delivery. The consumer must be idempotent to handle duplicates.

14. Used In (Case Studies)

SystemUse Case
StripeRequires Idempotency-Key headers for all charges and refunds.
UberDriver payout processing uses strict idempotency to ensure drivers aren't paid twice for the same ride.
WhatsAppMessage IDs act as idempotency keys. If the app resends a message, the server recognizes the ID and doesn't duplicate the text in the chat history.

15. Related Blocks

RelationshipBlock
Previous024 Message Queues & Pub/Sub
Parallel029 Retry & Exponential Backoff
Next026 Distributed Transactions

16. Try It Yourself

Exercise 1: Payload Validation

Modify the api_post_charge function to store the amount alongside the response in the cache. If a request comes in with a matching key, but a different amount, throw an error to prevent malicious behavior.

Exercise 2: Database Constraints

Imagine you don't have Redis. Design a SQL table schema for a payments table that natively guarantees idempotency using a UNIQUE constraint, removing the need for a separate idempotency store entirely.


Website Metadata

FieldValue
Hero TitleIdempotency
Hero SubtitleHow to safely retry failed operations, prevent double charges, and build robust APIs.
BreadcrumbSystem Design → Building Blocks → Idempotency
Sidebar CategoryTier 4 — Reliability
Search Keywordsidempotency, idempotent api, idempotency key, retry, payment api, double charge, stripe api, microservices
Internal Links← 024 Message Queues · → 029 Retry & Backoff
Suggested IllustrationA user furiously clicking a "Buy" button multiple times, but a shield in front of the database only lets the very first click through, deflecting the rest.
Suggested AnimationA request with a key enters the server. The server processes it. A second identical request enters. The server recognizes the key, skips processing, and instantly returns the cached receipt.
PreviousCAP Theorem & Consistency ModelsNextMessage Queues & Pub/Sub