Idempotency and Exactly-Once
Exactly-once delivery is not available; exactly-once effect is, built from idempotency keys, a dedup window and a unique constraint.
Idempotency-Key: <caller-generated, stable across every retry of one logical operation>A caller whose request times out cannot tell whether the request was lost or the response was. It has two options: retry, and risk a duplicate, or give up, and risk a lost write. Every real system retries. That makes at-least-once the delivery guarantee you actually have, and makes duplicate handling the receiver's job.
Why exactly-once delivery is not on the menu
It would require the sender to learn the receiver's state without a reply — the exact thing that just failed. Exactly-once processing is available inside one system that can commit the offset and the state change together: Kafka's idempotent producer (enable.idempotence, default true since Kafka 3.0, which also forces acks=all and retries=Integer.MAX_VALUE) combined with a transactional.id gives atomic read-process-write within Kafka. The guarantee ends at the first effect that leaves it: an email, a card charge, a webhook.
Keys and windows
An idempotency key is generated by the caller, stable across retries of one logical operation, and stored by the receiver alongside the response it produced. Stripe takes it in the Idempotency-Key header, accepts up to 255 characters, suggests v4 UUIDs, may prune keys after 24 hours, and errors if the same key arrives with different parameters. DynamoDB's TransactWriteItems uses ClientRequestToken with a 10-minute window and returns IdempotentParameterMismatch. SQS FIFO deduplicates on MessageDeduplicationId for 5 minutes.
The trap is the race, not the lookup: two retries can both pass a "have I seen this key?" check. Claim the key with a unique-constraint insert in the same transaction as the effect.
Example
// An idempotent POST where the database does the hard part. The key claim and
// the effect commit together, so there is no window in which a retry can see
// "key present, effect missing".
//
// CREATE TABLE idempotency_keys (
// key text PRIMARY KEY,
// request_hash text NOT NULL,
// status_code int,
// response jsonb,
// created_at timestamptz NOT NULL DEFAULT now()
// );
import crypto from "node:crypto";
app.post("/charges", async (req, res, next) => {
const key = req.get("Idempotency-Key");
if (!key) return res.status(400).json({ error: "Idempotency-Key required" });
const hash = crypto.createHash("sha256")
.update(JSON.stringify(req.body)).digest("hex");
const client = await pool.connect();
try {
await client.query("BEGIN");
// Claim the key. ON CONFLICT DO NOTHING is atomic: of N concurrent
// retries exactly one gets a row back. A retry that arrives while this
// transaction is open BLOCKS here until it commits or rolls back.
const claim = await client.query(
`INSERT INTO idempotency_keys (key, request_hash) VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING RETURNING key`,
[key, hash],
);
if (claim.rowCount === 0) {
await client.query("ROLLBACK");
const { rows } = await pool.query(
"SELECT request_hash, status_code, response FROM idempotency_keys WHERE key = $1",
[key],
);
if (rows[0].request_hash !== hash)
return res.status(422).json({ error: "key reused with different parameters" });
if (rows[0].status_code === null)
return res.status(409).json({ error: "request in progress" });
return res.status(rows[0].status_code).json(rows[0].response); // replay
}
const charge = await insertCharge(client, req.body); // the effect
await client.query(
"UPDATE idempotency_keys SET status_code = 201, response = $2::jsonb WHERE key = $1",
[key, JSON.stringify(charge)],
);
await client.query("COMMIT");
res.status(201).json(charge);
} catch (err) {
// The claim rolls back with everything else, so the key is free again and
// the caller's retry is a fresh attempt rather than a permanent 409.
await client.query("ROLLBACK").catch(() => {});
next(err);
} finally {
client.release();
}
});When to use it
- A payment endpoint that must not charge twice when the mobile client retries on timeout
- A queue consumer that may receive the same message more than once after a visibility timeout expires
- Deciding what to return when a retry arrives while the original request is still running
- Making a webhook handler safe when the sender replays deliveries
More examples
The caller's side: one key per operation, reused on every retry
Half of all duplicate-charge incidents are caused by the client, not the server: a key minted per HTTP attempt, or a body that differs by a timestamp between attempts. Use this shape in any SDK or job that calls a paid API.
// The key must be generated BEFORE the first attempt and reused by every
// retry. Generating it inside the retry loop defeats the whole mechanism.
import { randomUUID } from "node:crypto";
// randomUUID has been in node:crypto since Node 14.17; since Node 19 the same
// function is also reachable as the global crypto.randomUUID(). Same function,
// two spellings - the global form is the shorthand you will see in newer code.
export async function charge(body, { attempts = 4 } = {}) {
const idempotencyKey = randomUUID(); // once, for this logical operation
for (let attempt = 1; ; attempt++) {
try {
const res = await fetch("https://api.example.com/charges", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body), // byte-identical on every retry
signal: AbortSignal.timeout(10_000),
});
if (res.status === 409) throw new RetryableError("in progress");
if (res.status >= 500) throw new RetryableError(`upstream ${res.status}`);
return await res.json();
} catch (err) {
if (attempt >= attempts) throw err;
// Jittered backoff: a 409 means the first attempt is still running.
await new Promise((r) => setTimeout(r, Math.random() * 200 * 2 ** attempt));
}
}
}Consumer-side deduplication in the same transaction as the effect
The standard fix for "the worker ran twice because the visibility timeout expired mid-processing". Reach for it on any consumer whose effect is a database write, and keep external side effects out of the transaction so a rollback cannot un-send them.
// SQS and Kafka both deliver at-least-once. Dedup where the effect lands, in
// the same transaction, so a crash cannot split the two apart.
//
// CREATE TABLE processed_messages (
// message_id text PRIMARY KEY,
// consumer text NOT NULL,
// processed_at timestamptz NOT NULL DEFAULT now()
// );
export async function handleMessage(pool, message) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const seen = await client.query(
`INSERT INTO processed_messages (message_id, consumer)
VALUES ($1, 'invoice-emailer') ON CONFLICT (message_id) DO NOTHING
RETURNING message_id`,
[message.MessageId],
);
if (seen.rowCount === 0) {
await client.query("ROLLBACK");
return { skipped: "duplicate" }; // then delete the message from the queue
}
await applyEffect(client, JSON.parse(message.Body)); // database work only
await client.query("COMMIT");
return { applied: true };
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
throw err; // leave the message on the queue; the retry is now safe
} finally {
client.release();
}
}
// Note what cannot go inside the transaction: sending the actual email. Queue
// that as an outbox row and let a second worker send it with its own key.Making the effect itself conditional, with no dedup table
The cheapest form of idempotency, with no extra table and no expiry window to get wrong. It works whenever the operation is a one-way state transition; it does not work for operations with no natural guard, such as "append a row", which is where keys earn their keep.
// Sometimes you do not need a key at all: make the write a compare-and-set so
// the second attempt affects zero rows.
// 1. SQL compare-and-set on a state machine. The WHERE clause is the guard.
const { rowCount } = await pool.query(
`UPDATE payouts SET status = 'sent', sent_at = now()
WHERE id = $1 AND status = 'pending'`,
[payoutId],
);
if (rowCount === 0) {
// Already sent by an earlier attempt. Not an error - the desired state holds.
return { alreadySent: true };
}
await bank.send(payoutId); // only the attempt that won the transition does this
// 2. The same idea in DynamoDB: the condition fails on the replay.
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
try {
await ddb.send(new PutItemCommand({
TableName: "payouts",
Item: { id: { S: payoutId }, status: { S: "sent" } },
ConditionExpression: "attribute_not_exists(id)", // first writer only
}));
} catch (err) {
if (err.name !== "ConditionalCheckFailedException") throw err;
return { alreadySent: true };
}
Discussion