System Design

Idempotent APIs: Reliability in Distributed Systems

Learn to design idempotent APIs crucial for distributed systems reliability. Prevent duplicate operations, ensure data consistency, and build robust microservices.

Khader Vali July 2, 2026 16 min read

In the vast, interconnected world of modern software, distributed systems are the backbone of almost everything we interact with daily—from online banking to social media feeds. These systems, composed of numerous independent services communicating across networks, offer incredible scalability and resilience. However, this very distributed nature introduces a unique set of challenges. One of the most insidious and common problems is dealing with retries and their potential side effects, which can lead to data inconsistencies, incorrect states, and a poor user experience.

Imagine a user making an online payment. The client sends a request to the payment service. What happens if the network glitches just as the payment service processes the request, preventing the confirmation response from reaching the client? The user, seeing no confirmation, might click “Pay” again. Without careful design, this seemingly innocuous action could lead to a double charge, a frustrated customer, and a support ticket nightmare. This is where the concept of idempotency becomes not just a nice-to-have, but a critical pillar of reliability in distributed systems.

This article will delve deep into what idempotency is, why it’s indispensable for distributed systems, and, most importantly, how to design and implement idempotent APIs effectively. We’ll explore core principles, practical implementation strategies with code examples, architectural considerations, and real-world scenarios to equip you with the knowledge to build robust and trustworthy services.

What Exactly Is Idempotency?

At its core, idempotency is a property of an operation that states that performing it multiple times has the same effect as performing it once. In simpler terms, if you apply an idempotent operation, you can re-apply it over and over again, and the system’s state will not change further after the first successful application.

The term originates from mathematics, where an element x is idempotent under a binary operation * if x * x = x. For example, multiplying by zero is an idempotent operation: 0 * 5 = 0, and 0 * 0 = 0. In computing, familiar examples include:

  • HTTP GET requests: Retrieving data multiple times doesn’t change the data on the server.
  • HTTP PUT requests: Updating a resource with the same data multiple times results in the same resource state.
  • Deleting a resource: Deleting a resource once makes it disappear. Attempting to delete it again (if it’s already gone) has no further effect on the system’s state (it’s still gone).

Contrast this with non-idempotent operations, where each execution produces a new or different effect:

  • HTTP POST requests (by default): Creating a resource multiple times typically creates multiple copies of that resource.
  • Incrementing a counter: Each execution increases the counter’s value.
  • Transferring money: Each execution typically moves money, potentially leading to duplicate transfers.

In the context of APIs, an idempotent operation guarantees that sending the same request payload with the same parameters multiple times will have the same outcome on the server’s state. This guarantee is vital because in distributed systems, network unreliability, timeouts, and various forms of retry mechanisms are not exceptions, but fundamental aspects of operation.

The Perils of Non-Idempotent Operations in Distributed Systems

Distributed systems are inherently unpredictable. Network latency, temporary service unavailability, and unexpected errors are common occurrences. To cope with this, clients and services often implement retry logic. While retries are essential for resilience, they become a double-edged sword when interacting with non-idempotent operations, leading to a host of problems:

1. Network Failures and Timeouts

Consider a client sending a request to a server. There are several scenarios where a retry might occur:

  • Request Lost: The request never reaches the server. The client times out and retries. (Idempotency not strictly needed here, as the first request didn’t happen).
  • Response Lost: The request reaches the server, the server processes it successfully, but the response gets lost on its way back to the client. The client times out and retries. This is where non-idempotency causes issues. The server will process the operation a second time, leading to a duplicate.
  • Server Processing Timeout: The server receives the request but takes longer than the client’s timeout period to process it. The client retries while the first operation might still be in progress or just completed.

In the “response lost” and “server processing timeout” scenarios, a non-idempotent operation (like creating an order) would result in multiple orders being created, even though the client only intended one.

2. Message Queues and “At-Least-Once” Delivery

Message queues (e.g., Kafka, RabbitMQ, SQS) are fundamental for asynchronous communication in distributed systems. Many message brokers offer “at-least-once” delivery guarantees to ensure messages are not lost. This means a consumer might receive the same message multiple times under certain circumstances (e.g., consumer crashes after processing but before committing offset, or network partitions). If the operation triggered by the message is not idempotent, each duplicate message will lead to a duplicate action, causing inconsistencies.

3. Client-Side Retries and User Behavior

Users are impatient. If they click a “Submit” button and nothing seems to happen, they might click it again. Modern frontends often implement retry mechanisms for API calls that fail transiently. Without idempotency, these user-driven or client-side retries can lead to the same problems as server-side retries.

4. Concurrency and Race Conditions

In a highly concurrent environment, multiple instances of a service or multiple clients might attempt to perform what should logically be a single operation. For example, if two services try to decrement the same inventory item at the same time, without idempotency and proper locking, you could end up with incorrect stock levels.

Real-World Consequences

The practical implications of non-idempotent operations are severe:

  • Financial Discrepancies: Double charging customers, duplicate payouts, incorrect balance updates.
  • Data Corruption: Duplicate records (orders, users, transactions), incorrect inventory counts, inconsistent states across services.
  • Operational Overhead: Manual reconciliation processes, customer support dealing with complaints, auditing to find and fix errors.
  • Reduced Trust: Users lose faith in a system that frequently makes mistakes.

To avoid these pitfalls, we must actively design our APIs to be idempotent, especially for any operation that modifies state and might be retried.

Idempotent APIs: Reliability in Distributed Systems
Generated Image

Designing Idempotent APIs: Core Principles

Achieving idempotency for state-changing operations requires a deliberate design approach. It’s not something you can sprinkle on at the end; it needs to be baked into the API contract and implementation. Here are the core principles:

1. The Idempotency Key: Your Unique Fingerprint

The most crucial concept for making state-changing operations idempotent is the idempotency key. This is a unique identifier provided by the client for each distinct logical operation they wish to perform. It’s essentially a fingerprint for a request.

  • Client-Generated: Typically, the client generates this key (e.g., a UUID v4) and sends it with the request, often in an HTTP header like Idempotency-Key. This is critical because the client is the one that might retry, and it needs to use the same key for all retries of a single logical operation.
  • Uniqueness: Each distinct logical operation must have a unique idempotency key. If a client wants to create two separate orders, it must use two different idempotency keys.
  • Consistency: For any retries of the *same* logical operation, the client *must* use the *same* idempotency key.

2. State Management: Tracking and Preventing Duplicates

For the server to process requests idempotently, it needs to track the state of operations associated with idempotency keys. This involves:

  • Idempotency Store: A persistent storage mechanism (database, distributed cache like Redis) to record which idempotency keys have been seen and the status of their associated operations.
  • “In Progress” State: When a server receives a request with a new idempotency key, it should immediately record that an operation for this key is “in progress.” This prevents concurrent requests with the same key from both initiating the business logic.
  • Storing Results: Once an operation completes successfully, its result (e.g., the newly created resource ID, a success message, the HTTP status code) should be stored alongside the idempotency key. If a subsequent request arrives with the same key, the server can simply return the previously stored result without re-executing the business logic.

3. Atomicity and Transactions

The entire process of checking the idempotency key, executing the business logic, and storing the result (and updating the key’s status) must be atomic. If any part of this sequence fails, the system should revert to its previous state as if the operation never happened (or mark it as failed appropriately). Database transactions are indispensable for achieving this atomicity.

4. Stateless vs. Stateful Logic

While APIs are often designed to be stateless (meaning each request contains all necessary information and the server doesn’t retain client context between requests), implementing idempotency requires a degree of statefulness *on the server-side* to track the idempotency keys and their associated operation statuses and results. This isn’t about maintaining client session state, but rather maintaining operation processing state.

Implementation Strategies and Patterns

Let’s dive into practical ways to implement idempotency. The most common and robust pattern relies on idempotency keys and an associated store.

Pattern 1: Check-then-Execute (and Store Result)

This is the most comprehensive pattern for ensuring idempotency across a wide range of state-changing operations. It involves a dedicated idempotency store.

Flow Description:

  1. Client Initiates Request: The client generates a unique Idempotency-Key (e.g., a UUID v4) and sends it in an HTTP header along with the request payload.
  2. Server Receives Request: The API gateway or the service itself intercepts the request.
  3. Check Idempotency Store: The service checks its idempotency store (database or cache) to see if this Idempotency-Key has been seen before.
    • If Key Found (Completed): If the key exists and its associated operation has already completed successfully (and the request payload matches the original), the server retrieves the original response associated with that key and returns it immediately. No business logic is re-executed.
    • If Key Found (In Progress): If the key exists and its associated operation is currently in progress, the server should return a 409 Conflict status code or, in some advanced scenarios, block and wait for the original operation to complete (though this adds complexity and potential for deadlocks). Returning 409 is generally safer and signals the client to potentially wait and retry without a new key.
    • If Key Not Found: This is a new, unique operation.
  4. Record “In Progress” State: The service records the Idempotency-Key in its store, marking it as “in progress,” and optionally stores the original request payload for future comparison. This step must be atomic with the subsequent business logic execution.
  5. Execute Business Logic: The core business logic is executed (e.g., process payment, create order, update inventory). This typically involves database operations within a transaction.
  6. Store Result & Mark Completed: Once the business logic successfully completes, the service stores the final response (HTTP status, body) in the idempotency store, updates the key’s status to “completed,” and commits the transaction.
  7. Return Response: The service returns the response to the client.

Idempotency Store Schema Example (SQL Database):


CREATE TABLE idempotency_keys (
    idempotency_key VARCHAR(255) PRIMARY KEY,
    request_hash VARCHAR(255) NOT NULL, -- Hash of the request body + headers (excluding idempotency_key)
    response_status INT,
    response_body TEXT,
    status VARCHAR(50) NOT NULL, -- 'PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED'
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP
);
  • idempotency_key: The unique identifier provided by the client.
  • request_hash: A hash of the original request’s body and relevant headers. This is crucial to ensure that if the client retries with the same `Idempotency-Key` but a *different* request body, the server can detect this and return an error (e.g., 400 Bad Request), as it implies a different logical operation.
  • response_status, response_body: The HTTP status code and body of the first successful response.
  • status: Tracks the state of the operation (e.g., PENDING, IN_PROGRESS, COMPLETED, FAILED).
  • expires_at: An optional field for automatic garbage collection of old keys.

Code Example (Python/Flask for Payment Processing):

Let’s illustrate this with a simplified Flask application for processing payments. We’ll use a dictionary as our in-memory idempotency store for simplicity, but in production, this would be a database or Redis.


import uuid
import hashlib
import json
from flask import Flask, request, jsonify, make_response
import time

app = Flask(__name__)

# In-memory idempotency store for demonstration
# In production, this would be a database or a distributed cache like Redis
idempotency_store = {} # {idempotency_key: {'status': 'IN_PROGRESS/COMPLETED', 'response': {...}, 'request_hash': '...'}}

# Simulate a database for payments
payments_db = {} # {payment_id: {amount, currency, status}}
next_payment_id = 1

def generate_request_hash(request_data, headers):
    """Generates a hash from relevant parts of the request."""
    # We only care about the body for this example, but in real-world you'd include
    # important headers too (e.g., Content-Type, Authorization, etc.)
    # Ensure consistent serialization for hashing
    data_string = json.dumps(request_data, sort_keys=True)
    return hashlib.sha256(data_string.encode('utf-8')).hexdigest()

def process_payment_logic(amount, currency):
    """Simulates the actual payment processing logic."""
    global next_payment_id
    print(f"Processing payment: {amount} {currency}...")
    # Simulate some work and potential failure
    time.sleep(1) # Simulate network/processing delay
    if amount <= 0:
        raise ValueError("Invalid amount")
    
    payment_id = str(next_payment_id)
    next_payment_id += 1
    
    # Simulate a successful payment
    payment_record = {
        "payment_id": payment_id,
        "amount": amount,
        "currency": currency,
        "status": "completed",
        "timestamp": time.time()
    }
    payments_db[payment_id] = payment_record
    print(f"Payment {payment_id} processed successfully.")
    return payment_record

@app.route('/payments', methods=['POST'])
def create_payment():
    idempotency_key = request.headers.get('Idempotency-Key')
    if not idempotency_key:
        return jsonify({"error": "Idempotency-Key header is required"}), 400

    request_data = request.get_json()
    if not request_data:
        return jsonify({"error": "Request body must be JSON"}), 400

    current_request_hash = generate_request_hash(request_data, request.headers)

    # --- Idempotency Check ---
    if idempotency_key in idempotency_store:
        entry = idempotency_store[idempotency_key]
        
        # Check if the request body is identical to the original
        if entry.get('request_hash') != current_request_hash:
            return jsonify({
                "error": "Idempotency-Key used with a different request payload",
                "original_request_hash": entry.get('request_hash'),
                "current_request_hash": current_request_hash
            }), 400 # Or 422 Unprocessable Entity
            
        if entry['status'] == 'COMPLETED':
            print(f"Returning stored response for Idempotency-Key: {idempotency_key}")
            response = make_response(jsonify(entry['response']), entry['response_status'])
            response.headers['X-Idempotency-Key-Status'] = 'COMPLETED'
            return response
        elif entry['status'] == 'IN_PROGRESS':
            print(f"Operation already in progress for Idempotency-Key: {idempotency_key}")
            return jsonify({"error": "Operation already in progress"}), 409
        elif entry['status'] == 'FAILED':
            # If a previous attempt failed, we might want to retry it.
            # For simplicity, let's allow retries for FAILED states.
            print(f"Previous operation failed for Idempotency-Key: {idempotency_key}, retrying...")
            pass # Proceed to execute business logic again

    # --- If Key Not Found or FAILED, start a new attempt ---
    print(f"Processing new request for Idempotency-Key: {idempotency_key}")
    
    # Store 'IN_PROGRESS' state atomically
    # In a real system, this would involve a transaction in the DB or a distributed lock
    # on the idempotency_key. Here, we simulate by directly writing to the dict.
    idempotency_store[idempotency_key] = {
        'status': 'IN_PROGRESS',
        'request_hash': current_request_hash,
        'response': None,
        'response_status': None
    }

    try:
        amount = request_data.get('amount')
        currency = request_data.get('currency')

        if not isinstance(amount, (int, float)) or not isinstance(currency, str):
            raise ValueError("Invalid amount or currency")

        payment_result = process_payment_logic(amount, currency)
        
        # Store successful result
        idempotency_store[idempotency_key]['status'] = 'COMPLETED'
        idempotency_store[idempotency_key]['response'] = payment_result
        idempotency_store[idempotency_key]['response_status'] = 201 # Created
        
        response = make_response(jsonify(payment_result), 201)
        response.headers['X-Idempotency-Key-Status'] = 'NEWLY_PROCESSED'
        return response

    except ValueError as e:
        # Handle business logic errors
        print(f"Payment processing failed: {e}")
        idempotency_store[idempotency_key]['status'] = 'FAILED'
        idempotency_store[idempotency_key]['response'] = {"error": str(e)}
        idempotency_store[idempotency_key]['response_status'] = 400
        response = make_response(jsonify({"error": str(e)}), 400)
        response.headers['X-Idempotency-Key-Status'] = 'FAILED'
        return response
    except Exception as e:
        # Handle unexpected errors
        print(f"An unexpected error occurred: {e}")
        idempotency_store[idempotency_key]['status'] = 'FAILED'
        idempotency_store[idempotency_key]['response'] = {"error": "Internal server error"}
        idempotency_store[idempotency_key]['response_status'] = 500
        response = make_response(jsonify({"error": "Internal server error"}), 500)
        response.headers['X-Idempotency-Key-Status'] = 'FAILED'
        return response

if __name__ == '__main__':
    app.run(debug=True, port=5000)

To test this:


# First request (new key)
curl -X POST -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" -d '{"amount": 100, "currency": "USD"}' http://127.0.0.1:5000/payments

# Example of a client retrying a request (same key)
# Generate a key once, then use it for multiple requests
export MY_KEY=$(uuidgen)
curl -X POST -H "Content-Type: application/json" -H "Idempotency-Key: $MY_KEY" -d '{"amount": 50, "currency": "USD"}' http://127.0.0.1:5000/payments
# Immediately retry with the same key
curl -X POST -H "Content-Type: application/json" -H "Idempotency-Key: $MY_KEY" -d '{"amount": 50, "currency": "USD"}' http://127.0.0.1:5000/payments

# Try with a different amount but same key (should fail)
curl -X POST -H "Content-Type: application/json" -H "Idempotency-Key: $MY_KEY" -d '{"amount": 60, "currency": "USD"}' http://127.0.0.1:5000/payments

# Test an invalid amount
curl -X POST -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" -d '{"amount": -10, "currency": "USD"}' http://127.0.0.1:5000/payments

Considerations for Pattern 1:

  • Concurrency Control: In a multi-threaded or distributed environment, steps 3 and 4 (checking and recording “in progress” state) must be protected by a distributed lock or database transaction to prevent race conditions where two concurrent requests with the same key both try to process the logic. A unique constraint on the `idempotency_key` column in the database is essential.
  • Garbage Collection: Idempotency keys should not live forever. They consume storage. Implement a strategy to periodically clean up old keys (e.g., after a few days or weeks, depending on your retry policies).
  • Client Responsibility: Clients must generate unique keys for each *new* logical operation and reuse the *same key* for retries of that operation.

Pattern 2: Unique Constraints at the Database Level

For operations that involve creating a resource with a naturally unique identifier (e.g., creating a user with a unique email or username, or creating a payment with a transaction ID that is also the idempotency key), you can leverage database unique constraints for idempotency.

Flow Description:

  1. Client sends a request (e.g., POST /users).
  2. Server attempts to insert the new resource into the database.
  3. If the insert succeeds, the resource is created.
  4. If the insert fails due to a unique constraint violation (e.g., email already exists), the server catches this specific error.
  5. The server then retrieves the existing resource and returns it, potentially with a 200 OK or 409 Conflict status, indicating that the resource already exists.

Code Example (User Registration):


import sqlite3
from flask import Flask, request, jsonify

app = Flask(__name__)
DATABASE = 'users.db'

def init_db():
conn = sqlite3.connect(DATABASE)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()

@app.before_first_request
def setup_db():
init_db()

@app.route('/users', methods=['POST'])
def register_user():
user_data = request.get_json()
username = user_data.get('username')
email = user_data.get('email')

if not username or not email:
return jsonify({"error": "Username and email are required"}), 400

conn

Written by

Khader Vali

Senior Software Engineer specializing in cloud architecture, real-time systems, and enterprise-scale applications.

Share this article

Related Articles

CQRS & Event Sourcing for Scalable Applications

Jun 16, 2026 · 13 min read

Saga Pattern for distributed transactions in microservices architecture diagram

Saga Pattern: Taming Distributed Transactions in Microservices

May 30, 2026 · 16 min read

Strangler Fig Pattern: Safely Migrating Monoliths

Jun 19, 2026 · 19 min read