idempotent-core
v1.0.0
Published
Bulletproof, zero-dependency idempotency middleware for Node.js. Eliminates race conditions on critical endpoints (payments, webhooks) for Express, Fastify, and native HTTP.
Maintainers
Readme
IdempotentCore
🇫🇷 Lire en français | 🇬🇧 You are reading the English version
Bulletproof, zero-dependency idempotency middleware for Node.js.
Completely eliminates race conditions on your critical endpoints (Stripe payments, Mobile Money, webhooks, destructive actions) — compatible with Express, Fastify, and native http.
📊 Project dashboard
| Metric | Value |
|---|---|
| 📦 Production dependencies | 0 — only node:crypto, node:async_hooks, node:events |
| ⚡️ Measured average overhead / request | < 0.5 ms (see benchmarks) |
| 🧪 Test status | See CI badge above (Node 18 / 20 / 22 — Ubuntu & Windows) |
| 🧩 Supported frameworks | Express, Fastify, native http |
| 🔒 License | MIT — free for commercial use |
| 🗂️ Package size | A few KB (nothing to download, zero deps) |
| 🖥️ OS tested in CI | Ubuntu, Windows |
Why this package exists
Two identical requests hit /charge at the same time (double-click, network retry, webhook retry from a payment provider). Without protection: two charges, two emails, two database rows. IdempotentCore guarantees that only one logical request actually executes, even under heavy concurrency, without touching an external database or a distributed lock system — everything happens in native V8 memory, with sub-millisecond overhead.
Key features
- 🔒 Atomic locking: a concurrent duplicate request receives a clean
409 Conflictinstantly — double execution is structurally impossible. - ⚡️ Zero dependencies: only
node:crypto,node:async_hooks,node:events. Nonode_modulesto audit. - 🧠 Automatic deterministic fingerprinting: no need to force an
Idempotency-Keyheader on the client — a stable SHA-256 hash (method + URL + user + normalized body) protects even third-party webhooks you don't control. - 🔁 Instant replay: any duplicate request received AFTER processing finished gets the exact cached response, byte for byte — no re-execution.
- 🧵 Full async traceability via
AsyncLocalStorage: the idempotency context flows through everyawait, callback, and logging layer with no extra parameter to thread through. - 🧹 Automatic memory cleanup (TTL sweep): no memory-leak risk, even after millions of requests.
- 🧩 Pluggable storage: native in-memory by default, with an open interface to plug in Redis/Memcached for multi-instance environments.
- 🛡️ Safe by default: a
5xxresponse is never cached — the lock is released to allow a real retry; only success/client-error responses (< 500) are frozen. - 📡 Native observability: events (
lock:acquired,lock:conflict,response:cached,response:replayed,engine:error...) to wire up Prometheus/logs metrics without imposing any dependency.
Installation
npm install idempotent-coreNo dependency will be installed — the package is 100% zero-dep.
Quick start (Express)
const express = require('express');
const { idempotency } = require('idempotent-core');
const app = express();
app.use(express.json());
app.post(
'/api/charge',
idempotency({ ttl: 24 * 60 * 60 * 1000 }), // cache 24h, Stripe-style
async (req, res) => {
// Your critical logic here: Stripe call, DB write, etc.
const charge = await stripe.charges.create({ amount: req.body.amount });
res.status(201).json({ success: true, chargeId: charge.id });
}
);
app.listen(3000);That's it. Two identical concurrent requests → only one executes, the other gets a 409. A third request afterward → the cached response is replayed instantly.
Not just Stripe: works with any provider
The middleware knows nothing about what it protects — it just captures the response and blocks duplicates. It works identically with Orange Money, Wave, MTN Mobile Money, CinetPay, PayPal, a bank transfer, or even a plain account creation:
app.post(
'/api/mobile-money/pay',
idempotency({ ttl: 5 * 60 * 1000 }), // 5 min cache
async (req, res) => {
const { phoneNumber, amount, operator } = req.body;
const tx = await mobileMoneyProvider.initiate({ phoneNumber, amount, operator });
res.status(201).json({ transactionId: tx.id });
}
);If the client double-clicks or their network retries the request (common with unstable connections), only one charge goes through — guaranteed.
Using an explicit header (Stripe-style)
POST /api/charge HTTP/1.1
Idempotency-Key: 7f3a9c2e-... (client-generated)
Content-Type: application/json
{ "amount": 4999 }If the Idempotency-Key header is present, it's used as-is for the lock key — automatic fingerprinting only kicks in when the header is absent.
Using native http
const http = require('http');
const { idempotency } = require('idempotent-core');
const middleware = idempotency({ ttl: 60_000 });
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
req.body = body ? JSON.parse(body) : {};
middleware(req, res, (err) => {
if (err) {
res.writeHead(500).end(JSON.stringify({ error: 'internal' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
});
server.listen(4000);Using Fastify
See examples/fastify-example.js — Fastify exposes reply.raw, the actual http.ServerResponse our middleware operates on directly.
Configuration options
| Option | Type | Default | Description |
|---|---|---|---|
| ttl | number | 86400000 (24h) | How long a COMPLETED response stays cached (ms). |
| processingTtl | number | = ttl | Safety ceiling for a PROCESSING lock — guarantees a process crash never blocks an endpoint forever. |
| sweepInterval | number | 30000 | Frequency (ms) of the background TTL sweep. 0 disables automatic sweeping. |
| store | StoreInterface | MemoryStore | Custom storage backend (see Redis section below). |
| engine | IdempotentCore | — | Reuse an existing instance (shares the lock table across multiple routers). |
| headerName | string | 'idempotency-key' | Client header name for an explicit key. |
| autoFingerprint | boolean | true | If false, requests without an explicit header are not protected. |
| methods | string[] | ['POST','PUT','PATCH','DELETE'] | HTTP methods intercepted. |
| isCacheable | (code:number)=>boolean | code < 500 | Decides whether a final status code should be cached or release the lock. |
| userIdExtractor | (req)=>string | — | Custom user-id extraction for fingerprinting. |
Internal architecture
┌─────────────────────────────────────────────────────────────────┐
│ idempotency() middleware │
│ │
│ 1. Explicit key (header) OR automatic SHA-256 fingerprint │
│ 2. engine.acquire(key) ──────────────► MemoryStore (native Map) │
│ │ │
│ ├── NEW → wrap res.write/end/writeHead │
│ │ → AsyncLocalStorage.run(...) │
│ │ → next() → real business logic │
│ │ → capture response → engine.complete() │
│ │ │
│ ├── PROCESSING → immediate 409 Conflict │
│ │ │
│ └── COMPLETED → replay of the cached payload │
│ │
│ TTL Sweep (setInterval, unref'd): purges expired entries │
└─────────────────────────────────────────────────────────────────┘Extending storage (Redis, for multi-instance deployments)
By default, MemoryStore works within a single process. If your SaaS runs across multiple instances/pods behind a load balancer, implement StoreInterface on top of your existing Redis client (without adding Redis as a dependency of the package itself):
const { StoreInterface, idempotency } = require('idempotent-core');
class RedisStore extends StoreInterface {
constructor(redisClient) {
super();
this.redis = redisClient;
}
async setIfNotExists(key, record, ttlMs) {
const result = await this.redis.set(key, JSON.stringify(record), 'PX', ttlMs, 'NX');
return result === 'OK';
}
async get(key) {
const raw = await this.redis.get(key);
return raw ? JSON.parse(raw) : null;
}
async update(key, record, ttlMs) {
await this.redis.set(key, JSON.stringify(record), 'PX', ttlMs);
return true;
}
async delete(key) {
return (await this.redis.del(key)) > 0;
}
async size() {
return this.redis.dbsize();
}
}
app.use(idempotency({ store: new RedisStore(myRedisClient) }));Observability
const { idempotency, IdempotentCore } = require('idempotent-core');
const engine = new IdempotentCore({ ttl: 60_000 });
engine.on('lock:conflict', ({ key }) => metrics.increment('idempotency.conflict'));
engine.on('response:replayed', ({ key, statusCode }) => metrics.increment('idempotency.replay'));
engine.on('engine:error', (err) => logger.error('idempotency engine error', err));
app.use(idempotency({ engine }));
// Health endpoint
app.get('/health/idempotency', (req, res) => res.json(engine.stats()));Testing the concurrency proof
Quick test (no dependency, works on Windows/macOS/Linux):
npm testThis runs test/sanity-check.js, which simulates concurrent requests against minimal fake req/res objects and automatically asserts (via assert): exactly one 201 + one 409, exactly one real execution, and an instant replay with no re-execution.
Full demo with a real Express server (2 terminals):
npm install --no-save express # only to run the demo
npm run test:demo:server # Terminal 1 — leave it running
npm run test:demo:client # Terminal 2 — fires the concurrent requestsThe client sends two identical concurrent requests, then a third one afterward, and prints a PASS/FAIL report proving:
- only one real execution happens (
chargeCounter === 1), - the concurrent request correctly gets a
409, - the late request gets a replayed response in under 100ms.
Production security & robustness
- Fail-open: if the engine itself fails (e.g. Redis unavailable), the request is let through unprotected rather than taking down all traffic — an
engine:errorevent is emitted for alerting. - No lock leaks: client disconnect (
res.close), synchronous exception, or process crash → the lock is released or expires automatically viaprocessingTtl. - No caching of server errors: a
5xxreleases the lock instead of being cached, so a legitimately retrying client never gets a stale failure replayed forever. - Binary buffer support: response capture also works for streamed/chunked payloads (Buffer concatenation).
📈 Benchmarks
Measured with test/sanity-check.js over 5000 consecutive unique requests (Node 20, standard dev machine):
| Scenario | Result | |---|---| | Average overhead per request (new key) | ~0.1 ms | | Blocked duplicate request (409) | < 1 ms (native Map lookup, no I/O) | | Replayed cached response | < 5 ms (no business-logic re-execution) |
These numbers are for the default in-memory backend (MemoryStore). With a remote backend (Redis), overhead naturally depends on network latency to your store.
🆚 Why not just roll my own?
| | Typical hand-rolled solution | IdempotentCore |
|---|---|---|
| Race condition on concurrent locking | Often overlooked (naive if (cache.has(key)) is not atomic) | Guaranteed atomic locking |
| Memory leaks | Frequent (no TTL, no sweep) | Built-in automatic TTL sweep |
| Streaming/chunked responses | Rarely handled | Captured via write/end interception |
| Async traceability | Manual, fragile | Built-in AsyncLocalStorage |
| Caching server errors | A common hidden trap | 5xx never cached by default |
| Dependencies | Varies | 0 |
🗺️ Roadmap
- [ ] Official Redis adapter published separately (
idempotent-core-redis) - [ ] Native Fastify plugin (
fastify-plugin) without manualreply.hijack()wiring - [ ] CLI diagnostics dashboard (
npx idempotent-core stats) - [ ] Formal comparative benchmarks (autobench + chart)
Have an idea or a specific need? Open an issue.
🤝 Contributing
Contributions are welcome! See CONTRIBUTING.md for instructions (tests, code style, PR process).
License
MIT — see LICENSE
