Most of us implement idempotency the same way the first time. Check whether you've seen the key, and if not, do the work and store the key so the next request knows:
app.post("/charge", async (req, res) => {
const key = req.get("Idempotency-Key");
if (await redis.get(key)) {
return res.json(JSON.parse(await redis.get(`${key}:result`)));
}
const result = await chargeCard(req.body); // real money leaves here
await redis.set(key, "done");
await redis.set(`${key}:result`, JSON.stringify(result));
res.json(result);
});
It reviews fine. Then two things happen in production.
Two retries arrive at the same moment. Both reach the get before either reaches the set, so both charge the card. A load test finds this one if you go looking.
The process dies after chargeCard and before redis.set. The money moved, the key was never written, and the retry charges again. This one you can't easily reproduce on purpose, and it happens on its own during any deploy.
Fixing the first properly means the check and the write have to be one atomic operation, so exactly one caller wins. That creates the next problem: the winner can die holding the key, so the claim needs a lease that expires. Which creates another: the "dead" worker might just be slow, wake up, and overwrite the real result with its stale one. So the write needs a fencing token that the reclaimer invalidates.
I got tired of rewriting that, so it's a library now:
import express from "express";
import { idempotency } from "idemkit/express";
import { RedisBackend } from "idemkit/redis";
const app = express();
app.use(idempotency({ backend: await RedisBackend.fromUrl(process.env.REDIS_URL) }));
app.post("/charge", async (req, res) => {
res.status(201).json(await chargeCard(req.body)); // runs once per Idempotency-Key
});
Same thing works for queue consumers (SQS, Kafka, RabbitMQ, BullMQ) and plain function calls. Redis, Postgres, Mongo, DynamoDB or in memory. Zero dependencies, Apache-2.0.
https://github.com/idemkit/idemkit
If you find it useful, a star would be much appreciated.
That's great to hear! If you have any thoughts about what is missing or can be improved, please let me know :)