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
npm install idempotency-shieldexpress is a peer dependency (you already have it). ioredis is only
needed if you use RedisStore.
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.
- No key on the request → passes straight through, unguarded (unless
required: true). - New key → the handler runs, and once the response finishes, it's cached under that key.
- Same key, same request body, already completed → the cached response is replayed verbatim; the handler is skipped entirely.
- 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. - 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. - 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.
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.
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
});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)}`;
},
});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> { /* ... */ }
}npm run build
node examples/basic.jsIt prints the exact curl commands to try, demonstrating a fresh request,
a replayed retry, and a rejected key reuse with a different body.
npm install
npm test # vitest
npm run typecheck # tsc --noEmit
npm run build # tsup -> dist/ (ESM + CJS + .d.ts)MIT