When a user double-clicks “Pay”, the network hiccups, a page gets reloaded, or the browser retries a request, your backend can accidentally create duplicates: extra charges, double orders, duplicate bookings. The safe answer is idempotency — making sure the same operation runs exactly once. In this post we’ll enable idempotency in Express with express-idempotency-middleware
.
TL;DR
- The client sends
Idempotency-Key: <unique-value>
. - If the same request comes again with the same key, the server returns the same response and marks it with
Idempotency-Status: cached
. - If the same key arrives with a different request (different body/route/custom fingerprint), the server returns 409 Conflict.
Install
npm i express-idempotency-middleware
# or
pnpm add express-idempotency-middleware
Works with Express 4/5. The package is ESM-ready.
Quick start
import express from "express";
import { idempotencyMiddleware, MemoryStore } from "express-idempotency-middleware";
const app = express();
app.use(express.json());
const store = new MemoryStore(); // for production prefer Redis/DB store
app.post(
"/orders",
idempotencyMiddleware({ store }),
(req, res) => {
// your business logic (create order / charge card / etc.)
res.status(201).json({ id: 123, ...req.body });
}
);
app.listen(3000, () => console.log("http://localhost:3000"));
What happens:
- The first request with a given
Idempotency-Key
executes your handler and the response is cached for a TTL (default 24h). - Later identical requests with the same key return the cached response with headers:
Idempotency-Status: cached
Idempotency-Replayed: true
- If the payload/route/fingerprint differs, the server returns 409 Conflict.
Try it with curl
KEY=$(uuidgen) # any unique string works
curl -i -X POST http://localhost:3000/orders -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" -d '{"a":1}'
# ← 201 Created
# Idempotency-Status: created
curl -i -X POST http://localhost:3000/orders -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" -d '{"a":1}'
# ← 201 Created
# Idempotency-Status: cached
# Idempotency-Replayed: true
curl -i -X POST http://localhost:3000/orders -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" -d '{"a":2}'
# ← 409 Conflict
# Idempotency-Status: conflict
Why you want this
- Payments, orders, bookings, registrations.
- Webhook consumers that must not process the same event twice.
- Any critical path where a duplicate affects money, inventory, or trust.
Core ideas
Idempotency-Key. A client-generated unique key (usually UUID v4).
Fingerprint. A stable hash of the request: method + route, normalized body, optional query, and your custom factor (e.g., tenant).
Cache vs conflict. Same key + same fingerprint ⇒ replay; same key + different fingerprint ⇒ 409.
5xx safety. 5xx responses are not cached; the key goes back to “free” so a retry can succeed.
Configuration
import { idempotencyMiddleware, MemoryStore } from "express-idempotency-middleware";
app.post(
"/pay",
idempotencyMiddleware({
store: new MemoryStore(),
// Which HTTP methods are guarded (default: ["POST"])
methods: ["POST", "PUT"],
// Require Idempotency-Key header (default: false)
requireKey: true,
// Time-to-live for cached responses (default: 24h)
ttlMs: 24 * 60 * 60 * 1000,
// What to do with concurrent identical requests
inFlight: {
strategy: "wait", // or "reject"
waitTimeoutMs: 5000, // only for "wait"
pollMs: 50
},
// What contributes to the fingerprint
fingerprint: {
includeQuery: false, // default false
maxBodyBytes: 64 * 1024,
custom: (req) => String(req.headers["x-tenant-id"] || "")
},
// Which headers may be replayed from cache
replay: {
headerWhitelist: ["location"] // content-type is always replayed
}
}),
(req, res) => {
// critical operation (charge, create booking, etc.)
res.setHeader("Location", "/pay/tx-123");
res.status(201).json({ ok: true });
}
);
Concurrency policy
-
inFlight.strategy: "reject"
→ a second identical request while the first is still running gets 409 withIdempotency-Status: inflight
. -
inFlight.strategy: "wait"
→ the second request waits until the first completes, then receives the cached response.
Fingerprint options
-
includeQuery
— include query string into the fingerprint (useful for some APIs). -
custom(req)
— add anything domain-specific (e.g.,tenantId
,userId
). - Body is normalized (key order, trimming strings) and truncated by
maxBodyBytes
.
Behavior summary (headers)
On first successful attempt:
Idempotency-Key: <your key>
Idempotency-Status: created
Idempotency-Replayed: false
On replay:
Idempotency-Key: <your key>
Idempotency-Status: cached
Idempotency-Replayed: true
On conflict (same key, different fingerprint):
Idempotency-Status: conflict
On in-flight (concurrency, "reject"):
Idempotency-Status: inflight
Retry-After: 1
Using a real store (Redis)
MemoryStore
is great for local/dev, but production usually spans multiple instances. Back the store with Redis (sketch):
import { createClient } from "redis";
import type { Store, CachedResponse, BeginResult } from "express-idempotency-middleware";
class RedisStore implements Store {
constructor(private r = createClient()) {}
async begin(key: string, fp: string, ttlMs: number): Promise<BeginResult> {
await this.r.connect();
// SETNX to claim in-flight; EX sets TTL (seconds)
const claimed = await this.r.set(`idem:${key}:lock`, fp, { NX: true, EX: Math.ceil(ttlMs/1000) });
if (claimed) return { kind: "started" };
const cachedRaw = await this.r.get(`idem:${key}:resp`);
if (!cachedRaw) return { kind: "inflight" };
const cached = JSON.parse(cachedRaw) as CachedResponse;
return cached.fingerprint === fp ? { kind: "replay", cached } : { kind: "conflict" };
}
async commit(key: string, data: CachedResponse): Promise<void> {
const ttl = await this.r.ttl(`idem:${key}:lock`);
const sec = ttl > 0 ? ttl : 60;
await this.r.set(`idem:${key}:resp`, JSON.stringify(data), { EX: sec });
}
async get(key: string) {
const raw = await this.r.get(`idem:${key}:resp`);
return raw ? (JSON.parse(raw) as CachedResponse) : null;
}
async abort(key: string) {
await this.r.del(`idem:${key}:lock`);
}
}
The exact Redis logic is up to you; the key point is claiming an in-flight lock and storing the final response with a TTL.
Testing tips
The package is designed for integration tests with Supertest/Vitest. Example pattern for concurrent requests:
import express from "express";
import request from "supertest";
import { idempotencyMiddleware, MemoryStore } from "express-idempotency-middleware";
function makeBlockingApp() {
const app = express();
app.use(express.json());
const store = new MemoryStore();
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
app.post("/slow",
idempotencyMiddleware({ store, inFlight: { strategy: "wait", waitTimeoutMs: 3000, pollMs: 10 }}),
async (_req, res) => { await gate; res.status(201).json({ ok: true }); }
);
return { app, release };
}
it("second concurrent call receives cached", async () => {
const { app, release } = makeBlockingApp();
const key = "k1";
const first = request(app).post("/slow").set("Idempotency-Key", key).send({ a: 1 });
const second = request(app).post("/slow").set("Idempotency-Key", key).send({ a: 1 });
setTimeout(() => release(), 100);
const r2 = await second.expect(201);
expect(r2.headers["idempotency-status"]).toBe("cached");
await first.expect(201);
});
Common pitfalls
- Client must generate the key and reuse it for the same operation. If the client changes the key on each retry, you won’t get idempotency.
- Don’t cache 5xx — that’s handled by the middleware. A 5xx should not “freeze” the key; a subsequent retry should be able to succeed.
-
Replay headers are filtered: by default only
content-type
is replayed; usereplay.headerWhitelist
for safe extras (e.g.,location
). Cookies and auth are never replayed.
Wrap-up
With express-idempotency-middleware
, “one click — one result” becomes the default: the first successful response is safely cached and replayed, while conflicting retries are rejected. Add it to payment flows, orders, bookings, and webhook consumers to eliminate accidental duplicates and make your API predictable under real-world network conditions.
- npm:
express-idempotency-middleware
- GitHub: search for the repository name or adapt to your org
If you build a Redis or Postgres store, consider publishing it — the community will appreciate pluggable backends.
Top comments (0)