Writing Idempotency Middleware for Express and Fastify

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.

Two frameworks, two interception qualities Express offers no outbound hook, so the middleware replaces res.json before calling next and must remember to cover send and end as well. Fastify exposes onSend, a first-class hook that receives the serialised payload for every response including thrown errors, so nothing is patched and no method can be missed. Express — patch the writer res.json = wrapped must patch before next() a handler using res.send is missed Fastify — onSend hook fastify.addHook('onSend', …) sees every response, including throws nothing patched, nothing missable
-- 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

Why res.json is patched before next() Three possible producers of a response are shown: the route handler emitting 201, a validation middleware emitting 422, and the global error handler emitting 500. All three arrows converge on a single patched res.json capture point, which decides between storing the response and releasing the key before delegating to the original res.json. A note explains that patching after next would miss the validation and error paths. route handler res.json(charge) → 201 validation middleware res.json(errors) → 422 global error handler res.json(problem) → 500 patched res.json status >= 500 → release otherwise → complete original res.json bytes leave the process Patch BEFORE next() — patching after it misses the validation and error paths entirely.
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
The disconnect that strands a key A request registers its key and begins work. The client disconnects before the response is written, so res.json is never called and the finalise step never runs. A close listener guarded by the same finalised flag releases the key immediately, instead of leaving it pending until the lease expires. key → pending client disconnects key stuck pending for the whole lease res.on('close', () => { if (!finalised) store.release(scoped) }) releases immediately — the retry is not blocked

SRE / observability checklist

  1. idempotency_finalise_error_total — Counter of failed complete/release writes. Page above 0.05% of wrapped requests; it is the direct cause of stuck keys.
  2. idempotency_registration_duration_ms — Histogram of the Lua round trip only. Alert if p99 exceeds 15 ms.
  3. idempotency_finalise_total{status} — Counter labelled by response status. Verify that 4xx values appear (stored) and 5xx values appear under release.
  4. idempotency_pending_age_seconds — Gauge of the oldest pending key. Alert above 120 s.
  5. idempotent_replay_total — Counter of replayed responses. A baseline of 0.5%–2% is healthy; a spike means a client retry loop.
  6. idempotency_key log field — On every request line for wrapped routes, so an incident can reconstruct the window after the keys expire.