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. As of 0.37.0 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, Cloudflare Workers, and Vercel Edge. 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.route({
  method: "POST",
  path: "/charges",
  operationId: "createCharge",
  request: { body: z.object({ amount: z.number() }) },
  responses: {
    201: { description: "created", body: z.object({ id: z.string() }) },
  },
  handler: 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 + body) and consults a pluggable store:

  • 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",
  }),
);

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.

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 — the client can safely retry.
  • The stored body is capped by maxResponseBytes to bound memory growth from large replies.