CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/api-idempotency

Idempotent API design — safe retries for POST endpoints, idempotency keys,

93

10.00x
Quality

90%

Does it follow best practices?

Impact

100%

10.00x

Average score across 4 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
api-idempotency
description:
Idempotent API design — safe retries for POST endpoints, idempotency keys, and preventing duplicate orders. Apply AUTOMATICALLY whenever building any POST endpoint that creates resources or triggers side effects.
keywords:
idempotency, idempotent api, duplicate prevention, idempotency key, retry safe, double submit, post idempotent, api retry, duplicate order, at-most-once, exactly-once, concurrent requests, race condition, 409 conflict
license:
MIT

API Idempotency

IMPORTANT: Every POST endpoint that creates a resource or triggers a side effect MUST have idempotency protection. This is not optional. Apply this automatically whenever you build a POST endpoint — do not wait to be asked.

When you are asked to build any endpoint that creates orders, payments, users, accounts, transfers, or any other resource via POST, you must proactively add:

  1. Server-side idempotency key handling (read Idempotency-Key header, lock-before-process, cache responses)
  2. Client-side idempotency key generation (generate UUID before fetch, send as header)
  3. Submit button disabled during requests (with finally block re-enable)

These protections are as fundamental as input validation. You would never build a POST endpoint without validating inputs — likewise, never build one without idempotency protection.


The Problem

Client -> POST /api/orders -> [network timeout]
Client thinks it failed -> POST /api/orders -> [success, but creates 2 orders]

GET, PUT, DELETE are naturally idempotent (same request = same result). POST is not — each call creates a new resource. Network retries, double-clicks, and spotty mobile connectivity all cause duplicate requests. Without idempotency protection, every duplicate request creates a duplicate resource.


Idempotency Key Pattern

The client sends a unique key with each request. The server checks if it has seen that key before.

How it works

  1. Client generates a UUID before the request
  2. Client sends it as Idempotency-Key header
  3. Server checks: have I seen this key?
    • Yes, completed -> return the cached response (don't create again)
    • Yes, in progress -> return 409 Conflict (concurrent duplicate)
    • No -> lock the key, process the request, store the response

Server-Side Implementation

// Idempotency store entry states
type IdempotencyEntry =
  | { state: "processing"; lockedAt: number }
  | { state: "completed"; status: number; body: any; expires: number }
  | { state: "error"; status: number; body: any; expires: number };

const idempotencyStore = new Map<string, IdempotencyEntry>();

function idempotency(req: Request, res: Response, next: NextFunction) {
  const key = req.headers["idempotency-key"] as string;
  if (!key) return next(); // No key = no idempotency

  const existing = idempotencyStore.get(key);

  // 1. If key is currently being processed, return 409 Conflict
  if (existing && existing.state === "processing") {
    return res.status(409).json({
      error: "A request with this idempotency key is already being processed",
    });
  }

  // 2. If completed and not expired, return the cached response
  if (existing && existing.state === "completed" && Date.now() < existing.expires) {
    return res.status(existing.status).json(existing.body);
  }

  // 3. If it was an error, allow retry (don't cache server errors permanently)
  // 4. If new or expired, lock and process
  idempotencyStore.set(key, { state: "processing", lockedAt: Date.now() });

  // Capture the response to cache it
  const originalJson = res.json.bind(res);
  res.json = (body: any) => {
    const statusCode = res.statusCode;
    if (statusCode >= 500) {
      // Don't cache server errors — allow the client to retry
      idempotencyStore.delete(key);
    } else {
      // Cache successful responses and client errors (4xx)
      idempotencyStore.set(key, {
        state: "completed",
        status: statusCode,
        body,
        expires: Date.now() + 24 * 60 * 60 * 1000, // 24h TTL
      });
    }
    return originalJson(body);
  };

  next();
}

// Apply to resource-creating endpoints only
router.post("/api/orders", idempotency, createOrderHandler);
router.post("/api/payments", idempotency, createPaymentHandler);

Critical rules for the server

  1. Lock before processing: Mark the key as "processing" before executing business logic to handle concurrent duplicates.
  2. Return 409 for concurrent duplicates: If a second request arrives while the first is still processing, respond with 409 Conflict rather than queuing or silently dropping it.
  3. Never cache 5xx errors: Server errors are transient. If you cache them, the client can never successfully retry. Delete the key entry on 5xx so the next retry gets a fresh attempt.
  4. Cache 4xx client errors: Validation errors (400, 422) should be cached — replaying the same bad request should return the same error, not re-run validation.
  5. Preserve the original status code: When returning a cached response, use the status code from the original response, not a hardcoded 200.
  6. TTL on all entries: Every cached entry must have an expiration. 24 hours is the standard (matches Stripe). Entries must not accumulate forever.
  7. Request fingerprinting: Optionally hash the request body and store it alongside the key. If the same key arrives with a different body, return 422 Unprocessable Entity — the client is reusing a key incorrectly.

Client-Side Implementation

async function placeOrder(orderData: CreateOrderRequest): Promise<OrderResponse> {
  const idempotencyKey = crypto.randomUUID();

  return executeWithRetry(async () => {
    const response = await fetch("/api/orders", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(orderData),
    });

    // On 409 Conflict (concurrent duplicate), wait and retry with SAME key
    if (response.status === 409) {
      throw new RetryableError("Concurrent request in progress");
    }

    if (!response.ok) {
      throw new Error(`Order failed: ${response.status}`);
    }

    return response.json();
  });
}

Key client-side rules

  1. Generate the key before the request: Create the UUID before the fetch call, not inside a callback or after the response.
  2. Reuse the same key on retry: When retrying a failed request, send the same idempotency key. Generate a new key only for a genuinely new operation.
  3. Handle 409 Conflict: If the server returns 409, it means your previous request is still being processed. Wait briefly and retry with the same key.

UI Double-Submit Prevention

Always disable the submit button during the API request. Use a finally block so the button is re-enabled even if the request fails:

const submitBtn = document.getElementById("submit") as HTMLButtonElement;
submitBtn.addEventListener("click", async () => {
  submitBtn.disabled = true;
  submitBtn.textContent = "Placing order...";
  try {
    await placeOrder(orderData);
  } finally {
    submitBtn.disabled = false;
    submitBtn.textContent = "Place Order";
  }
});

The finally block is critical. Without it, a network error leaves the button permanently disabled and the form unusable. This is a defense-in-depth measure — it does not replace server-side idempotency because it cannot protect against network retries or multiple browser tabs.


Database-Level Protection

Use unique constraints as the final safety net. Even if the application layer fails, the database prevents duplicates:

CREATE TABLE orders (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  idempotency_key TEXT NOT NULL UNIQUE,
  customer_name TEXT NOT NULL,
  amount DECIMAL(10,2) NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Index for fast lookups by idempotency key
CREATE INDEX idx_orders_idempotency_key ON orders(idempotency_key);

Handling the constraint violation

try {
  const result = db
    .prepare(
      "INSERT INTO orders (idempotency_key, customer_name, amount) VALUES (?, ?, ?)"
    )
    .run(idempotencyKey, customerName, amount);
  return db.prepare("SELECT * FROM orders WHERE id = ?").get(result.lastInsertRowid);
} catch (err: any) {
  if (err.code === "SQLITE_CONSTRAINT_UNIQUE") {
    // Duplicate key — return the existing record, not an error
    console.log(`Duplicate payment detected for key: ${idempotencyKey}`);
    return db
      .prepare("SELECT * FROM orders WHERE idempotency_key = ?")
      .get(idempotencyKey);
  }
  // Re-throw non-duplicate errors — don't swallow unexpected failures
  throw err;
}

Important: Only suppress the specific constraint error

The catch block must check for the unique constraint violation specifically (e.g., SQLITE_CONSTRAINT_UNIQUE, PostgreSQL error code 23505). All other database errors must be re-thrown. Swallowing all errors hides real bugs.


Concurrent Request Handling (Race Conditions)

The most dangerous idempotency bug is the race condition: two identical requests arrive at nearly the same time, both pass the "have I seen this key?" check, and both execute.

The solution: Lock-then-process

Request A arrives -> check key -> not found -> LOCK key as "processing"
Request B arrives -> check key -> found, state=processing -> return 409 Conflict
Request A finishes -> update key state to "completed" with cached response
Request B retries -> check key -> found, state=completed -> return cached response

In a database-backed store, use a transaction with SELECT ... FOR UPDATE or INSERT ... ON CONFLICT to atomically claim the key:

-- Atomically claim an idempotency key (PostgreSQL)
INSERT INTO idempotency_keys (key, status, created_at)
VALUES ($1, 'processing', NOW())
ON CONFLICT (key) DO NOTHING
RETURNING *;
-- If no row returned, the key already exists — check its status

For in-memory stores (Redis, Map), the single-threaded nature of Node.js or Redis commands provides natural serialization, but you must still track the "processing" state to handle concurrent requests that arrive before the first completes.


When to Apply Idempotency — ALWAYS for POST

Rule: If you are building a POST endpoint that creates a resource or triggers a side effect, add idempotency protection. No exceptions. Do not wait for the task to mention duplicates, retries, or idempotency — add it proactively.

EndpointNeeds idempotency?Why
POST /api/ordersYes — alwaysCreates resource, money involved
POST /api/paymentsYes — alwaysDouble charge is unacceptable
POST /api/usersYes — alwaysDuplicate accounts cause confusion
POST /api/transfersYes — alwaysFinancial — must be exactly-once
POST /api/anything-that-createsYes — alwaysAny resource creation needs it
PUT /api/orders/:idNoPUT is naturally idempotent
PATCH /api/orders/:id/statusNoPATCH is naturally idempotent
GET /api/menuNoGET is naturally idempotent
DELETE /api/orders/:idNoDeleting twice = same result

If you are building a form that submits to a POST endpoint, always disable the submit button during the request and send an Idempotency-Key header. If you are building a server POST endpoint, always add idempotency middleware that reads the key, locks before processing, and caches the response.


Checklist

  • POST endpoints that create resources accept an Idempotency-Key header
  • Server locks the key as "processing" before executing business logic
  • Concurrent duplicate requests receive 409 Conflict
  • Completed responses are cached with a TTL (24h standard)
  • 5xx server errors are NOT cached (allow retry)
  • 4xx client errors ARE cached (same bad input = same error)
  • Cached responses preserve the original HTTP status code
  • Database tables have UNIQUE constraints on idempotency key columns
  • Constraint violation catch blocks only suppress the specific duplicate error
  • Submit buttons are disabled during requests using a finally block
  • Client generates the idempotency key BEFORE making the request
  • Client reuses the same key when retrying, generates a new key for new operations

Verifiers

  • prevent-duplicate-submit — Prevent duplicate submissions on create endpoints
  • idempotency-key-handling — Server-side idempotency key handling with race condition protection
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/api-idempotency badge