Skip to content
Express.js ex api 5 min read

Idempotency in APIs

Networks fail in the most inconvenient ways: a client sends POST /payments, your server charges the card, and then the response is lost in transit. The client sees a timeout, retries, and now the customer is charged twice. Idempotency is the property that lets a request be sent more than once without changing the result beyond the first application. GET, PUT, and DELETE are naturally idempotent; POST is not. This page shows how to make unsafe Express endpoints safe to retry by threading an idempotency key through every mutating request and caching the original result.

What “idempotent” actually means

An operation is idempotent if performing it N times produces the same observable state as performing it once. Crucially, this is not the same as safe (no side effects at all). Creating a resource has side effects, but with idempotency keys we can guarantee the side effect happens exactly once even if the request is delivered repeatedly.

MethodIdempotent by spec?Needs an idempotency key?
GETYesNo
PUTYes (full replace)No
DELETEYesNo
POSTNoYes, for create/charge/transfer

The pattern follows the approach popularised by payment APIs like Stripe: the client generates a unique key (a UUID), sends it in an Idempotency-Key header, and the server promises that the same key always yields the same outcome.

The flow

When a request arrives with an idempotency key, the server does one of three things:

  1. First time seen — execute the work, persist the response under the key, and return it.
  2. Seen and completed — skip the work entirely and replay the stored response.
  3. Seen but still in flight — a concurrent retry is racing the original; reject with 409 Conflict rather than double-executing.

The store can be Redis (with a TTL of 24-48 hours) or a database table. The key insight is that the result must be written atomically alongside the side effect, or in a way the client can safely retry into.

A reusable middleware

The cleanest implementation is middleware that wraps the response. It claims the key before the handler runs, then captures whatever the handler sends and writes it back to the store. We use Redis with SET ... NX to claim the key atomically.

const express = require("express");
const Redis = require("ioredis");

const redis = new Redis();
const TTL_SECONDS = 60 * 60 * 24; // keep keys for 24h

function idempotency() {
  return async (req, res, next) => {
    const key = req.get("Idempotency-Key");
    if (!key) {
      return res.status(400).json({ error: "Idempotency-Key header required" });
    }

    const storeKey = `idem:${req.method}:${req.path}:${key}`;

    // Try to claim the key. NX = only set if absent.
    const claimed = await redis.set(storeKey, "pending", "EX", TTL_SECONDS, "NX");

    if (!claimed) {
      const stored = await redis.get(storeKey);
      if (stored === "pending") {
        // Original request is still running — tell the client to back off.
        return res.status(409).json({ error: "Request already in progress" });
      }
      // Completed earlier: replay the cached response verbatim.
      const { status, body } = JSON.parse(stored);
      return res.status(status).set("Idempotent-Replay", "true").json(body);
    }

    // First time: intercept res.json so we can persist the result.
    const originalJson = res.json.bind(res);
    res.json = (body) => {
      const record = JSON.stringify({ status: res.statusCode, body });
      // fire-and-forget cache write, then send as normal
      redis.set(storeKey, record, "EX", TTL_SECONDS).catch(() => {});
      return originalJson(body);
    };

    next();
  };
}

module.exports = idempotency;

Mount it only on the mutating routes that need it — not globally, since GET requests should never demand a key.

const app = express();
const idempotency = require("./idempotency");

app.use(express.json());

app.post("/payments", idempotency(), async (req, res, next) => {
  try {
    const { amount, currency, source } = req.body;
    const charge = await paymentGateway.charge({ amount, currency, source });
    res.status(201).json({ id: charge.id, amount, currency, status: "succeeded" });
  } catch (err) {
    next(err);
  }
});

Seeing it work

The first request executes the charge and returns the result. A retry with the same key replays the stored response and adds an Idempotent-Replay header, so the gateway is never hit twice.

# First call — performs the charge
curl -X POST http://localhost:3000/payments \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7e9c1f0a-2b6d-4f3a-9c11-aa00cd5512ef" \
  -d '{"amount": 4200, "currency": "usd", "source": "tok_visa"}'

# Retry — same key, no second charge
curl -X POST http://localhost:3000/payments \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7e9c1f0a-2b6d-4f3a-9c11-aa00cd5512ef" \
  -d '{"amount": 4200, "currency": "usd", "source": "tok_visa"}'

Output:

HTTP/1.1 201 Created
Content-Type: application/json
{ "id": "ch_3PqL", "amount": 4200, "currency": "usd", "status": "succeeded" }

HTTP/1.1 201 Created
Content-Type: application/json
Idempotent-Replay: true
{ "id": "ch_3PqL", "amount": 4200, "currency": "usd", "status": "succeeded" }

Gotcha: an idempotency key is only meaningful when paired with the same request body. A malicious or buggy client could reuse a key with a different payload. Hardened implementations store a hash of the request body alongside the key and return 422 Unprocessable Entity if a reused key arrives with mismatched parameters.

Database-backed alternative

If you don’t run Redis, a unique constraint in your database gives you the same atomic claim for free. Insert the key first inside the same transaction as the side effect:

app.post("/orders", idempotency(), async (req, res, next) => {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // Unique constraint on idempotency_key makes the second insert fail
    await client.query(
      "INSERT INTO idempotency_keys (key, route) VALUES ($1, $2)",
      [req.get("Idempotency-Key"), "/orders"]
    );
    const { rows } = await client.query(
      "INSERT INTO orders (sku, qty) VALUES ($1, $2) RETURNING id",
      [req.body.sku, req.body.qty]
    );
    await client.query("COMMIT");
    res.status(201).json({ id: rows[0].id });
  } catch (err) {
    await client.query("ROLLBACK");
    next(err);
  } finally {
    client.release();
  }
});

Tip: in Express 5, async handlers that reject are forwarded to your error middleware automatically, so the explicit try/catch and next(err) shown here become optional. The 4.x pattern above runs unchanged on both versions.

Best practices

  • Require an Idempotency-Key header on every non-idempotent mutation (charges, transfers, order creation) and let clients generate UUIDs for it.
  • Claim the key atomically — Redis SET NX or a unique database constraint — so concurrent retries can never both execute.
  • Scope keys by method and path so the same UUID across different endpoints never collides.
  • Store the original status code and body, and replay them byte-for-byte; signal replays with an Idempotent-Replay header.
  • Hash and compare the request body so a reused key with different parameters is rejected rather than silently mis-served.
  • Expire stored keys with a TTL (24-48 hours is typical) to bound storage while covering realistic retry windows.
  • Return 409 Conflict while the original request is still in flight, prompting the client to retry after a short delay.
Last updated June 14, 2026
Was this helpful?