Framework-agnostic idempotency for Node.js HTTP handlers and event consumers.
This package is for operations that must be safe to retry:
- charging a card
- creating an order
- processing a webhook
- handling a queue message that may be redelivered
If the same idempotency key is seen twice, the operation runs once and later duplicates get the stored result back.
Use it when:
- a client may retry a
POST,PATCH, orPUT - a webhook provider may deliver the same event more than once
- a queue or broker uses at-least-once delivery
- the handler has side effects you must not apply twice
Do not use it when:
- the route is just a
GETor another pure read - the operation is already naturally idempotent and duplicate execution is harmless
- you do not have a stable key from the caller or event source
npm install @xyph3r/idempotencyor
bun add @xyph3r/idempotencyFor a given key, the package stores one of two states:
processing: one request is currently executingcompleted: the original result is already stored and can be replayed
The normal flow is:
- claim the key
- run the operation once
- store the result
- replay the stored result for duplicates
Use Redis in production. MemoryStore is for local development and tests.
import { createClient } from "redis";
import {
IdempotencyBuilder,
RedisStore,
createNodeRedisExecutor,
} from "@xyph3r/idempotency";
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
export const idempotency = new IdempotencyBuilder()
.useStore(new RedisStore(createNodeRedisExecutor(client)))
.withTTL(24 * 60 * 60 * 1_000)
.withProcessingTTL(30_000)
.withPollInterval(50)
.build();import Redis from "ioredis";
import {
IdempotencyBuilder,
RedisStore,
createIORedisExecutor,
} from "@xyph3r/idempotency";
const client = new Redis(process.env.REDIS_URL!);
export const idempotency = new IdempotencyBuilder()
.useStore(new RedisStore(createIORedisExecutor(client)))
.withTTL(24 * 60 * 60 * 1_000)
.build();For HTTP, the best pattern is:
- protect only side-effecting routes
- require the caller to send a stable idempotency key
- wrap the actual route handler, not a global middleware chain
The adapters default to POST, PATCH, and PUT.
createExpressIdempotency() wraps the route handler directly.
import express from "express";
import {
IdempotencyBuilder,
createExpressIdempotency,
} from "@xyph3r/idempotency";
const app = express();
app.use(express.json());
const idempotency = new IdempotencyBuilder()
.withMemoryStore()
.withTTL(24 * 60 * 60 * 1_000)
.build();
app.post(
"/payments",
createExpressIdempotency(
idempotency,
async (request, response) => {
const payment = await chargeCard(request.body);
response.status(201);
response.json?.(payment);
},
{
key: (request) =>
request.headers["idempotency-key"] as string | undefined,
},
),
);Use this for routes like:
POST /paymentsPOST /ordersPOST /subscriptions/:id/cancel
Wrap the route handler you register with Fastify.
import Fastify from "fastify";
import {
IdempotencyBuilder,
createFastifyIdempotency,
} from "@xyph3r/idempotency";
const app = Fastify();
const idempotency = new IdempotencyBuilder()
.withMemoryStore()
.withTTL(24 * 60 * 60 * 1_000)
.build();
app.post(
"/orders",
createFastifyIdempotency(
idempotency,
async (request, reply) => {
const order = await createOrder(request.body);
reply.code(201);
reply.send(order);
},
{
key: (request) =>
request.headers["idempotency-key"] as string | undefined,
},
),
);Use createFetchIdempotency() when your handler already looks like (request) => Response.
import {
IdempotencyBuilder,
createFetchIdempotency,
} from "@xyph3r/idempotency";
const idempotency = new IdempotencyBuilder()
.withMemoryStore()
.build();
const handler = createFetchIdempotency(
idempotency,
async (request) => {
const body = await request.json();
const payment = await chargeCard(body);
return Response.json(payment, { status: 201 });
},
{
key: (request) => request.headers.get("idempotency-key") ?? undefined,
},
);import { createFetchIdempotency, IdempotencyBuilder } from "@xyph3r/idempotency";
const idempotency = new IdempotencyBuilder()
.withMemoryStore()
.build();
Bun.serve({
fetch: createFetchIdempotency(
idempotency,
async (request) => {
const body = await request.json();
const result = await createCheckout(body);
return Response.json(result, { status: 201 });
},
{
key: (request) => request.headers.get("idempotency-key") ?? undefined,
},
),
});Wrap the Hono route handler itself.
import { Hono } from "hono";
import {
IdempotencyBuilder,
createHonoIdempotency,
} from "@xyph3r/idempotency";
const app = new Hono();
const idempotency = new IdempotencyBuilder()
.withMemoryStore()
.build();
app.post(
"/payments",
createHonoIdempotency(
idempotency,
async (c) => {
const body = await c.req.raw.json();
const payment = await chargeCard(body);
return c.json(payment, 201);
},
{
key: (c) => c.req.header("idempotency-key"),
},
),
);Wrap the exported route handler.
import {
IdempotencyBuilder,
createNextIdempotency,
} from "@xyph3r/idempotency";
const idempotency = new IdempotencyBuilder()
.withMemoryStore()
.build();
export const POST = createNextIdempotency(
idempotency,
async (request) => {
const body = await request.json();
const order = await createOrder(body);
return Response.json(order, { status: 201 });
},
{
key: (request) => request.headers.get("idempotency-key") ?? undefined,
},
);If your key depends on route params:
export const POST = createNextIdempotency(
idempotency,
async (request, context: { params: { orderId: string } }) => {
return Response.json({ orderId: context.params.orderId });
},
{
key: (request, context) =>
request.headers.get("idempotency-key") ??
`${context.params.orderId}:${request.headers.get("x-request-id") ?? ""}`,
},
);This is where the package becomes especially useful.
At-least-once delivery means duplicate events are normal. Consumers need a stable event key and must be safe to call twice.
Use the provider event ID as the key.
import {
IdempotencyBuilder,
createIdempotentConsumer,
} from "@xyph3r/idempotency";
const idempotency = new IdempotencyBuilder()
.withMemoryStore()
.withTTL(7 * 24 * 60 * 60 * 1_000)
.build();
const handleStripeEvent = createIdempotentConsumer(
idempotency,
async (event: { id: string; type: string; data: { object: { invoiceId: string } } }) => {
if (event.type === "invoice.paid") {
await markInvoicePaid(event.data.object.invoiceId);
}
},
{
key: (event) => event.id,
},
);Use the message or event ID from the broker payload.
import {
IdempotencyBuilder,
createIdempotentConsumer,
} from "@xyph3r/idempotency";
const idempotency = new IdempotencyBuilder()
.withMemoryStore()
.build();
const handleOrderPaid = createIdempotentConsumer(
idempotency,
async (message: { eventId: string; orderId: string }) => {
await reserveInventory(message.orderId);
await createShipment(message.orderId);
},
{
key: (message) => message.eventId,
},
);Use this when:
- the broker may redeliver after a worker crash
- a webhook sender retries until it gets
2xx - consumers trigger emails, charges, provisioning, or other side effects
If you do not want a framework adapter, wrap your function directly.
import {
IdempotencyBuilder,
createIdempotentHandler,
} from "@xyph3r/idempotency";
const idempotency = new IdempotencyBuilder()
.withMemoryStore()
.build();
const createPayment = createIdempotentHandler(
idempotency,
async (context: {
body: unknown;
headers: Record<string, string | undefined>;
}) => {
return chargeCard(context.body);
},
{
key: (context) => context.headers["idempotency-key"],
},
);A good key must be stable across retries for the same logical operation.
Good keys:
- client-provided
Idempotency-Key - Stripe or GitHub webhook event ID
- broker message ID or event ID
- a server-generated operation ID returned earlier to the client
Bad keys:
- a random UUID generated inside the handler
- current timestamp
- request body hash if the same operation may legitimately repeat later
By default:
- only
POST,PATCH, andPUTare protected by the HTTP adapters - duplicates wait briefly for the first execution to finish
- completed results are replayed
- failed executions are not stored as completed results
Headers added to HTTP responses:
idempotency-keyidempotency-status: created | cached
const idempotency = new IdempotencyBuilder()
.useStore(store)
.withTTL(24 * 60 * 60 * 1_000)
.withProcessingTTL(30_000)
.withPollInterval(50)
.withInFlightStrategy("wait")
.withDefaultHeader("idempotency-key")
.withKeyPrefix("payments")
.build();Use withInFlightStrategy("reject") if you want duplicates to fail immediately while the first request is still running instead of waiting.
IdempotencyBuilderIdempotencyManagercreateIdempotency()createIdempotentHandler()createIdempotentConsumer()createExpressIdempotency()createFastifyIdempotency()createFetchIdempotency()createHonoIdempotency()createNextIdempotency()MemoryStoreRedisStore