Skip to content

Repository files navigation

idempotency-shield

Express middleware that makes handlers safe to retry.

Clients (or flaky networks, or retrying job queues) sometimes send the same POST/PATCH/DELETE request twice. Without protection, that means double charges, duplicate orders, or double-sent emails. idempotency-shield lets clients attach an Idempotency-Key header; the first request runs normally and its response is cached, and any retry with the same key gets the cached response replayed instead of re-executing the handler.

  • Framework: Express (4 or 5)
  • Storage: in-memory (single instance) or Redis (multi-instance), or bring your own by implementing a 4-method interface
  • Zero runtime dependencies

Install

npm install idempotency-shield

express is a peer dependency (you already have it). ioredis is only needed if you use RedisStore.

Quick start

import express from "express";
import { idempotencyMiddleware, MemoryStore } from "idempotency-shield";

const app = express();
app.use(express.json());

const store = new MemoryStore();
app.use(idempotencyMiddleware({ store }));

app.post("/charges", (req, res) => {
  const charge = createCharge(req.body); // only ever runs once per key
  res.status(201).json(charge);
});
curl -X POST http://localhost:3000/charges \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8f14e45f-ea" \
  -d '{"amount": 500}'

Send that exact request again with the same key and you get back the exact same response (plus an Idempotent-Replayed: true header) — the handler does not run a second time.

How it works

  1. No key on the request → passes straight through, unguarded (unless required: true).
  2. New key → the handler runs, and once the response finishes, it's cached under that key.
  3. Same key, same request body, already completed → the cached response is replayed verbatim; the handler is skipped entirely.
  4. Same key, different request body → rejected with 422. Reusing a key for a different payload is treated as a client bug, the same way Stripe's API does it.
  5. Same key, still in flight (a concurrent duplicate) → rejected with 409, so two copies of the same request can never race each other into the handler.
  6. Handler responds with a 5xx → the response is not cached and the record is dropped, so the client can safely retry the same key after a transient failure. This is configurable via shouldCacheResponse.

Using Redis (multi-instance deployments)

MemoryStore only works if every retry lands on the same process. Once you run more than one instance, use RedisStore so all instances share state:

import Redis from "ioredis";
import { idempotencyMiddleware, RedisStore } from "idempotency-shield";

const redis = new Redis(process.env.REDIS_URL);
const store = new RedisStore(redis);

app.use(idempotencyMiddleware({ store, ttlMs: 60 * 60 * 1000 }));

RedisStore relies on Redis's atomic SET key value NX to guarantee that when two requests race for the same key, exactly one of them wins and runs the handler.

Options

idempotencyMiddleware({
  store,                     // required: an IdempotencyStore
  headerName: "Idempotency-Key",
  ttlMs: 24 * 60 * 60 * 1000, // how long a completed response stays replayable
  methods: ["POST", "PATCH", "DELETE"], // which methods are guarded
  required: false,           // 400 if the header is missing on a guarded method
  failOpen: true,            // let requests through unguarded if the store throws
  fingerprint: (req) => ..., // customize what "the same request" means
  shouldCacheResponse: (statusCode) => statusCode < 500,
  onError: (error, req) => logger.warn(error), // observe store failures
});

Custom fingerprinting

By default, two requests are considered "the same" if they share method + path + JSON body. Override this if your notion of duplicate is different, e.g. ignoring a requestedAt timestamp field the client always varies:

idempotencyMiddleware({
  store,
  fingerprint: (req) => {
    const { requestedAt, ...stable } = req.body ?? {};
    return `${req.method}:${req.originalUrl}:${JSON.stringify(stable)}`;
  },
});

Bring your own store

Implement IdempotencyStore to back this with Postgres, DynamoDB, etc. The only hard requirement is that create is atomic — "insert if absent" — so concurrent duplicates can't both proceed:

import type { IdempotencyStore, IdempotencyRecord } from "idempotency-shield";

class PostgresStore implements IdempotencyStore {
  async create(key: string, record: IdempotencyRecord, ttlMs: number): Promise<boolean> {
    // INSERT ... ON CONFLICT (key) DO NOTHING, return whether a row was inserted
  }
  async get(key: string): Promise<IdempotencyRecord | undefined> { /* ... */ }
  async update(key: string, record: IdempotencyRecord, ttlMs: number): Promise<void> { /* ... */ }
  async delete(key: string): Promise<void> { /* ... */ }
}

Runnable example

npm run build
node examples/basic.js

It prints the exact curl commands to try, demonstrating a fresh request, a replayed retry, and a rejected key reuse with a different body.

Development

npm install
npm test         # vitest
npm run typecheck # tsc --noEmit
npm run build     # tsup -> dist/ (ESM + CJS + .d.ts)

License

MIT

About

Express middleware that makes handlers safe to retry — replays the cached response for a duplicate Idempotency-Key instead of re-running it, with pluggable in-memory or Redis storage.

Topics

Resources

Stars

85 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages