This is the Node implementation of the five middleware obligations described in idempotency middleware & framework integration: extract and scope the key, fingerprint the body, register atomically, capture the response, and finalise. The Express version relies on patching the response writer; the Fastify version uses a proper hook and is the better shape if you have the choice.
Prerequisites. A Redis instance (single node or cluster), express.json() or Fastify’s built-in body parsing already buffering the body, and an authentication layer that has populated a credential on the request before this middleware runs.
Step 1 — The atomic register-or-get script
Registration must be one round trip. A GET followed by a SET NX is two, and two concurrent requests can both observe an absent key between them.
-- register_or_get.lua
-- KEYS[1] = scoped idempotency key
-- ARGV[1] = fingerprint hex, ARGV[2] = lease ms
-- Returns: {"fresh"} | {"hit", state, fingerprint, status, body}
local existing = redis.call('HGETALL', KEYS[1])
if #existing > 0 then
local h = {}
for i = 1, #existing, 2 do h[existing[i]] = existing[i + 1] end
return {'hit', h.state or '', h.fingerprint or '', h.status or '', h.body or ''}
end
redis.call('HSET', KEYS[1], 'state', 'pending', 'fingerprint', ARGV[1])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return {'fresh'}
The script is atomic because Redis executes it as a single unit — no other command interleaves between the HGETALL and the HSET. That property is the entire guarantee; everything below is plumbing around it.
const fs = require('node:fs');
const REGISTER = fs.readFileSync(`${__dirname}/register_or_get.lua`, 'utf8');
class RedisIdempotencyStore {
constructor(redis, { leaseMs = 60_000, ttlMs = 86_400_000 } = {}) {
this.redis = redis; this.leaseMs = leaseMs; this.ttlMs = ttlMs;
this.redis.defineCommand('registerOrGet', { numberOfKeys: 1, lua: REGISTER });
}
async registerOrGet(key, fingerprint) {
const r = await this.redis.registerOrGet(key, fingerprint, this.leaseMs);
if (r[0] === 'fresh') return null;
return { state: r[1], fingerprint: r[2], status: Number(r[3]) || 0, body: r[4] };
}
complete(key, status, body) {
return this.redis.multi()
.hset(key, 'state', 'completed', 'status', String(status), 'body', JSON.stringify(body))
.pexpire(key, this.ttlMs)
.exec();
}
release(key) { return this.redis.del(key); }
}
Step 2 — The Express middleware
const crypto = require('node:crypto');
const KEY_RE = /^[A-Za-z0-9_-]{16,255}$/;
function canonical(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
return `{${Object.keys(value).sort()
.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
}
function idempotency(store, { required = true } = {}) {
return async function idempotencyMiddleware(req, res, next) {
const raw = req.get('Idempotency-Key');
if (!raw) {
if (!required) return next();
return res.status(400).json({ code: 'idempotency_key_required' });
}
if (!KEY_RE.test(raw)) {
return res.status(400).json({ code: 'idempotency_key_invalid' });
}
const scoped = `idem:v1:${req.credential.id}:${req.route?.path ?? req.path}:${raw}`;
const fingerprint = crypto.createHash('sha256')
.update(canonical(req.body ?? null)).digest('hex');
let hit;
try {
hit = await store.registerOrGet(scoped, fingerprint);
} catch (err) {
req.log.error({ err, scoped }, 'idempotency store unreachable');
return res.set('Retry-After', '2').status(503)
.json({ code: 'idempotency_store_unavailable' }); // fail closed
}
if (hit) {
if (hit.fingerprint !== fingerprint) {
return res.status(422).json({ code: 'idempotency_key_payload_mismatch' });
}
if (hit.state === 'pending') {
return res.set('Retry-After', '1').status(409)
.json({ code: 'idempotency_key_in_use' });
}
return res.set('Idempotent-Replay', 'true')
.status(hit.status).json(JSON.parse(hit.body));
}
// Fresh key: patch the writer NOW so every downstream producer is captured.
const originalJson = res.json.bind(res);
let finalised = false;
res.json = (body) => {
if (!finalised) {
finalised = true;
const done = res.statusCode >= 500 || res.statusCode === 429
? store.release(scoped)
: store.complete(scoped, res.statusCode, body);
done.catch((err) => {
req.log.error({ err, scoped, status: res.statusCode },
'idempotency finalise failed — key will stick until lease expiry');
req.metrics.inc('idempotency_finalise_error_total');
});
}
res.set('Idempotent-Replay', 'false');
return originalJson(body);
};
return next();
};
}
module.exports = { idempotency, canonical };
Two details that are easy to get wrong. The finalised flag guards against a handler that calls res.json twice — rare, but it would otherwise write the key twice and log a spurious error. And 429 joins the 5xx release set, because a rate-limited first attempt is transient and must not poison the key for its whole TTL.
Step 3 — The Fastify version
Fastify gives you a real hook, so nothing is monkey-patched.
const fp = require('fastify-plugin');
module.exports = fp(async function idempotencyPlugin(fastify, opts) {
const store = opts.store;
fastify.decorateRequest('idemKey', null);
fastify.addHook('preHandler', async (request, reply) => {
if (!opts.routes.has(request.routeOptions.url)) return;
const raw = request.headers['idempotency-key'];
if (!raw) return reply.code(400).send({ code: 'idempotency_key_required' });
if (!KEY_RE.test(raw)) return reply.code(400).send({ code: 'idempotency_key_invalid' });
const scoped = `idem:v1:${request.credential.id}:${request.routeOptions.url}:${raw}`;
const fingerprint = sha256(canonical(request.body ?? null));
const hit = await store.registerOrGet(scoped, fingerprint);
if (!hit) { request.idemKey = scoped; return; }
if (hit.fingerprint !== fingerprint) {
return reply.code(422).send({ code: 'idempotency_key_payload_mismatch' });
}
if (hit.state === 'pending') {
return reply.header('Retry-After', '1').code(409)
.send({ code: 'idempotency_key_in_use' });
}
return reply.header('Idempotent-Replay', 'true')
.code(hit.status).send(JSON.parse(hit.body));
});
// onSend sees the serialised payload for EVERY response, including thrown errors.
fastify.addHook('onSend', async (request, reply, payload) => {
if (!request.idemKey) return payload;
try {
if (reply.statusCode >= 500 || reply.statusCode === 429) {
await store.release(request.idemKey);
} else {
await store.complete(request.idemKey, reply.statusCode, JSON.parse(payload));
}
} catch (err) {
request.log.error({ err, key: request.idemKey }, 'idempotency finalise failed');
}
reply.header('Idempotent-Replay', 'false');
return payload;
});
});
onSend runs for thrown errors as well as normal returns, which makes the release path reliable without any of the Express caveats.
Step 4 — Wire it up
const express = require('express');
const Redis = require('ioredis');
const app = express();
const store = new RedisIdempotencyStore(new Redis(process.env.REDIS_URL));
app.use(express.json({ limit: '1mb' })); // bounded — fingerprinting is not free
app.use(authenticate); // populates req.credential
app.use(rateLimit); // cheap rejections before the store
app.post('/v1/charges', idempotency(store), createCharge);
// The error handler MUST emit through res.json for the patch to see it.
app.use((err, req, res, _next) => {
req.log.error({ err }, 'unhandled');
res.status(err.status ?? 500).json({ code: err.code ?? 'internal_error' });
});
Order matters: authentication before idempotency (the credential scopes the key), rate limiting before it too (a rejected request should never touch Redis), and the error handler last so its output still passes through the patched writer.
Step 5 — Prove it under concurrency
const { test } = require('node:test');
const assert = require('node:assert');
test('64 concurrent duplicates produce exactly one charge', async () => {
for (let trial = 0; trial < 50; trial += 1) {
const key = ulid();
const barrier = new Promise((resolve) => setTimeout(resolve, 20));
const results = await Promise.all(
Array.from({ length: 64 }, async () => {
await barrier; // release together
const r = await fetch(`${BASE}/v1/charges`, {
method: 'POST',
headers: { 'Idempotency-Key': key, 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 4500, currency: 'gbp' }),
});
return r.status;
}),
);
assert.equal(results.filter((s) => s === 201).length, 1, `trial ${trial}`);
assert.equal(results.filter((s) => s === 409).length, 63);
assert.equal(await chargeCount(key), 1); // the effect, not just the codes
}
});
Assert on the charge count, not only the status distribution. A broken store can return one 201 and 63 409s while having executed the handler twice.
# Same check from the shell, against a running service.
KEY=$(uuidgen | tr -d '-')
seq 64 | xargs -P 64 -I{} curl -s -o /dev/null -w '%{http_code}\n' \
-XPOST localhost:8080/v1/charges -H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' -d '{"amount":4500,"currency":"gbp"}' \
| sort | uniq -c
# expect: 1 × 201, 63 × 409
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
res.json patched inside the route handler rather than in the middleware, so 422 and 500 responses are never stored or released |
Patch before next() in the middleware and assert with a test that a validation failure leaves a completed key with status 422. |
idempotency_finalise_total by status; the absence of 4xx values proves the error paths bypass the capture |
Handler uses res.send or res.end instead of res.json, so nothing is captured |
Patch send and end as well, or enforce res.json with a lint rule on the routes covered by the middleware. |
idempotency_pending_age_seconds climbing on routes that appear healthy is the signature |
express.json() registered after the idempotency middleware, so req.body is undefined and every request fingerprints identically |
Register the body parser first. Add a startup assertion that req.body is defined for a canary POST. |
idempotency_payload_mismatch_total at zero combined with a fingerprint histogram of cardinality one |
Redis reachable but slow; every request waits 2 s on registerOrGet, and the endpoint’s latency doubles |
Set a command timeout (commandTimeout: 200) on the client and treat a timeout as store-unavailable under the configured policy. |
idempotency_registration_duration_ms p99; alert above 15 ms |
Client disconnects and Express aborts the response, so res.json is never called and the key sticks in pending |
Add a res.on('close') handler that releases the key when no finalise has run, guarded by the same finalised flag. |
idempotency_abandoned_total from that handler; idempotency_pending_age_seconds above the lease |
SRE / observability checklist
idempotency_finalise_error_total— Counter of failedcomplete/releasewrites. Page above0.05%of wrapped requests; it is the direct cause of stuck keys.idempotency_registration_duration_ms— Histogram of the Lua round trip only. Alert if p99 exceeds15 ms.idempotency_finalise_total{status}— Counter labelled by response status. Verify that 4xx values appear (stored) and 5xx values appear underrelease.idempotency_pending_age_seconds— Gauge of the oldestpendingkey. Alert above120 s.idempotent_replay_total— Counter of replayed responses. A baseline of 0.5%–2% is healthy; a spike means a client retry loop.idempotency_keylog field — On every request line for wrapped routes, so an incident can reconstruct the window after the keys expire.
Related
- Idempotency Middleware & Framework Integration — the parent page defining the five obligations this implementation discharges.
- A FastAPI Idempotency Dependency with Redis — the Python equivalent, including the body-caching trap.
- Adding an Idempotency Filter to Spring Boot — the JVM equivalent with content-caching wrappers.
- Redis Lua Scripts for Atomic Idempotency State Machines — the script in step 1, extended to the full state machine.