Skip to content

Search docs

Jump between documentation pages.

Browse docs

Idempotency keys

Network retries are a fact of life on serverless platforms, behind load balancers, and on flaky mobile connections. For unsafe methods (POST, PUT, PATCH, DELETE) a blind retry can charge a card twice or create a duplicate order. The idempotency() middleware gives those requests an exactly-once guarantee: the client sends a unique Idempotency-Key header, and DaloyJS makes sure the side effect runs at most once no matter how many times the request is replayed.

It is built-in and dependency-free, built on Web Crypto and the Web-standard Request/Response, so it runs unchanged on Node, Bun, Deno, and Cloudflare Workers. The behavior mirrors the IETF Idempotency-Key HTTP Header Field draft and the conventions used by major payment processors.

Quick start

Mount idempotency() ahead of the routes that need exactly-once semantics. That is all: clients opt in per request by sending an Idempotency-Key header.

ts
import { App, idempotency } from "@daloyjs/core";
import { z } from "zod";

const app = new App();

// Safe retries for the whole write surface.
app.use(idempotency({ ttlSeconds: 86_400 }));

app.post(
  "/charges",
  {
    operationId: "createCharge",
    request: { body: z.object({ amount: z.number() }) },
    responses: {
      201: { description: "created", body: z.object({ id: z.string() }) },
    },
  },
  async ({ body }) => {
    const id = await chargeCard(body.amount); // runs at most once per key
    return { status: 201 as const, body: { id } };
  },
);

How it works

For an applicable method that carries an Idempotency-Key header, the middleware fingerprints the request (method + path + query string + body) and consults a pluggable store:

Same key, replayed request
Clientidempotency()StoreHandler
  1. 01requestClientidempotency()POST /charges with Idempotency-Keyfirst attempt
  2. 02asyncidempotency()Storereserve(key): atomic set-if-absentwins the reservation
  3. 03requestidempotency()HandlerRun handler once, capture responsecomplete(key, response) for ttlSeconds
  4. 04requestClientidempotency()Identical retry, same key + fingerprintnetwork hiccup, client retries
  5. 05responseidempotency()ClientReplay stored response, handler skippedIdempotency-Replayed: true
The first request runs the handler and stores its response under the key. An identical retry replays that stored response byte for byte without running the side effect again. A retry while the first is still in flight gets a 409, and the same key with a different body gets a 422.
  • First request: the handler runs normally; the final response is captured and persisted under the key for ttlSeconds.
  • Identical retry (same key, same fingerprint, original completed): the stored response is replayed byte-for-byte with an Idempotency-Replayed: true header. The handler does not run again.
  • Retry while the first is still in flight: a 409 Conflict is returned (with Cache-Control: no-store) so the client backs off instead of racing.
  • Same key, different body: a 422 Unprocessable Content is returned. A key is permanently bound to the first payload it was used with.

Responses that are not safe to cache are never stored, and the reservation is released so the client can retry: server errors (5xx by default, see cacheableStatus) and responses larger than maxResponseBytes(1 MiB by default).

Options

ts
app.use(
  idempotency({
    // How long a key (and its replayed response) lives. Default: 86400 (24h).
    ttlSeconds: 86_400,
    // Request header carrying the key. Default: "idempotency-key".
    headerName: "idempotency-key",
    // Response header marking a replay. Default: "idempotency-replayed".
    replayHeaderName: "idempotency-replayed",
    // Methods the middleware applies to. Default: POST, PUT, PATCH, DELETE.
    methods: ["POST", "PUT", "PATCH", "DELETE"],
    // Reject applicable requests that omit the header with 400. Default: false.
    requireKey: false,
    // Maximum accepted key length. Default: 255.
    maxKeyLength: 255,
    // Largest response body buffered + stored. Default: 1 MiB.
    maxResponseBytes: 1_048_576,
    // Decide whether a response is cached. Default: status < 500.
    cacheableStatus: (status) => status < 500,
    // Share one in-memory store across mounts with the same id.
    groupId: "payments",
    // Namespace keys by caller. Default: hash of the Authorization header.
    scope: (ctx) => (ctx.state.session as { id?: string } | undefined)?.id,
  }),
);

Pluggable stores

The default MemoryIdempotencyStore is process-local, perfect for tests and single-instance deployments. For a multi-instance or serverless fleet, supply a shared backend by implementing IdempotencyStore. The contract mirrors SessionStore and the rate-limit store: the one rule is that reserve()must be atomic (“set if absent”), the exact SET key value NX semantics of Redis, so two concurrent requests cannot both win the reservation. The key passed to your store is already namespaced by groupId and scope.

ts
import type { IdempotencyStore, IdempotencyRecord } from "@daloyjs/core";

const redisIdempotencyStore: IdempotencyStore = {
  // Atomic reserve: persist only if the key is unused, else return the
  // existing record untouched.
  async reserve(key, record, ttlMs) {
    const ok = await redis.set(key, JSON.stringify(record), "PX", ttlMs, "NX");
    if (ok) return null;
    const raw = await redis.get(key);
    return raw ? (JSON.parse(raw) as IdempotencyRecord) : null;
  },
  async complete(key, record, ttlMs) {
    await redis.set(key, JSON.stringify(record), "PX", ttlMs);
  },
  async release(key) {
    await redis.del(key);
  },
};

app.use(idempotency({ store: redisIdempotencyStore }));

Client usage

Clients generate a unique key per logical operation (a UUID is ideal) and reuse it across retries of that same operation:

ts
const key = crypto.randomUUID();

async function createChargeWithRetries(amount: number) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch("/charges", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "idempotency-key": key, // same key on every retry
      },
      body: JSON.stringify({ amount }),
    });
    if (res.status !== 409) return res; // 409 = still in flight, back off
    await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
  }
  throw new Error("charge still in flight after retries");
}

Security notes

  • Keys are validated up front: empty, over-long (maxKeyLength), or non-printable keys are rejected with 400 Bad Request before any store lookup.
  • Conflict and reuse responses (409, 422) carry Cache-Control: no-store so a shared cache cannot mask them.
  • Server errors are never cached, so a transient 5xx does not poison the key, so the client can safely retry.
  • The stored body is capped by maxResponseBytes to bound memory growth from large replies.
  • Keys are namespaced per principal (CWE-524).Without this, any client that reused another client's Idempotency-Keywith the same body would receive that client's stored response. The store key is namespaced by the caller, defaulting to the Authorization header so the common bearer- / API-key case is isolated automatically. For cookie-based sessions, pass a stable identity via scope, e.g. scope: (ctx) => (ctx.state.session as { id?: string } | undefined)?.id. Unauthenticated requests (no Authorization, no scope) still dedupe by key alone.