DEV Community

Yevhen Mykhailenko
Yevhen Mykhailenko

Posted on

How to prevent duplicate charges, orders, and form submissions in Node.js/Express using express-idempotency-middleware.

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
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
  }
);
Enter fullscreen mode Exit fullscreen mode

Concurrency policy

  • inFlight.strategy: "reject" → a second identical request while the first is still running gets 409 with Idempotency-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
Enter fullscreen mode Exit fullscreen mode

On replay:

Idempotency-Key: <your key>
Idempotency-Status: cached
Idempotency-Replayed: true
Enter fullscreen mode Exit fullscreen mode

On conflict (same key, different fingerprint):

Idempotency-Status: conflict
Enter fullscreen mode Exit fullscreen mode

On in-flight (concurrency, "reject"):

Idempotency-Status: inflight
Retry-After: 1
Enter fullscreen mode Exit fullscreen mode

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`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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; use replay.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)