A command can reach the app twice: the client retried after a timeout, the user clicked twice, a
webhook was delivered again. The aggregate rejects what it must (placeOrder again is
AlreadyPlaced), but a rejection is the wrong answer to a retry of a command that went through,
and a command the aggregate takes twice, such as adding a line, does its work twice. An
idempotency key names one dispatch, so a second arrival under it answers what the first did
instead of running again. What the runtime already guarantees for the events and reactions inside
the app is in delivery guarantees; this page is about commands
that come from outside it.
The idempotencyKey option
Section titled “The idempotencyKey option”const placed = await app.commands.placeOrder(payload, { idempotencyKey: key });The first dispatch under a key runs the command and remembers what it answered, in the same
transaction as the events it stores. Every later dispatch with the same key, the same command,
the same payload and the same delay answers the same without running the handler: the same StoredDispatch, its
position included, so reading your own writes
waits for the same read models; the same ScheduledDispatch for a command with delay, which is
scheduled once; or the same rejection, thrown again as a DomainError with its code. A dispatch
that failed, with a ConcurrencyError after its retries, a HANDLER_TIMEOUT or an error of the
handler’s, is not remembered, so the client’s retry runs it.
Two dispatches racing for one key both run; one records its outcome and the other loses on the key, rolls back whatever it stored, and answers what the key holds. The events land once.
The key names one command with one payload and one delay. The same key with another command,
with another payload, or scheduled when it ran at once (or the other way round, or with another
delay), is a bug of the caller, not a retry: the dispatch rejects with
IdempotencyKeyConflictError (IDEMPOTENCY_KEY_CONFLICT) and runs nothing. The payload is
compared as JSON, keys in any order, with maps, sets and bigints compared by what they hold.
A key is a string of 1 to 255 characters that the caller chooses, one per operation the client
means to do once, such as a UUID made when the form is submitted and sent again on every retry of
it. It is remembered for runtime.commands.idempotencyKeyLifetime, 24 hours by default; past it
the key is free for another command, and the scheduled-command worker purges the record from
bounda_idempotency_keys (configuration).
A rejection decided on a state that held someone’s personal data is
remembered only until that person is forgotten: forget removes it, and the same key is then
decided again.
This is not the idempotencyKey a command handler receives: that one is the command’s own id,
for the provider the handler calls, the same across the runs a concurrency conflict causes
(calling the outside world). The
option is for whoever dispatches the command, and reaches the handler as command.metadata
does not: a replay never runs it.
Keys in React Router
Section titled “Keys in React Router”The boundaMiddleware takes a request’s key from its Idempotency-Key header or, when there is
none, from the idempotencyKey field of the form it posts, urlencoded or multipart. It gives every
command the request dispatches an idempotencyKey of its own, idempotencyKeyFor(key, commandName), on Node and on Cloudflare alike, so an action has nothing to add, even one that
dispatches two commands. The form is read from a copy of the request, so the action still reads it
with request.formData(); a multipart form is therefore parsed twice, files included, so a form
that declares a length over a mebibyte is not read for its key: a route that takes uploads keys
its requests with the header. The host never makes keys up: a request with neither
dispatches as before, and an empty key is refused like any other.
From a form
Section titled “From a form”A browser form sends no headers, so a form carries its key in a hidden field.
@bounda-dev/react-router/form has both halves: createIdempotencyKey() makes one in the loader,
so the page hydrates with the key it was rendered with, and <IdempotencyKeyField> renders it:
import { bounda, failure } from "@bounda-dev/react-router/app";import { createIdempotencyKey, IdempotencyKeyField } from "@bounda-dev/react-router/form";import { Form, redirect } from "react-router";import type { Route } from "./+types/activate";
export const loader = () => ({ idempotencyKey: createIdempotencyKey() });
export const action = async ({ request, context }: Route.ActionArgs) => { const form = await request.formData(); const userId = String(form.get("userId")); try { // Keyed with the form's idempotencyKey field. await context.get(bounda).commands.activateUser({ userId }); } catch (error) { return failure(error); } return redirect(`/users/${userId}`);};
export default function Activate({ loaderData }: Route.ComponentProps) { return ( <Form method="post"> <IdempotencyKeyField value={loaderData.idempotencyKey} /> <input name="userId" required /> <button type="submit">Activate</button> </Form> );}The same form submitted again unchanged, twice in a row or after a response that never came back, sends the same key and answers what the first submit did. Once the user edits the form, the field takes a fresh key, so a form corrected after a rejection is a new request rather than a conflict: React Router does not run the loader again after an action that answers 4xx, which would otherwise leave the old key in place. A new key from the loader, after a successful submit, replaces it too. A payload that comes from the page rather than the user, such as the id of what the form creates, belongs in the loader as well, so that a retry sends the same one:
export const loader = () => ({ userId: crypto.randomUUID(), idempotencyKey: createIdempotencyKey() });From fetch
Section titled “From fetch”A client that calls the action itself, a script or another service, sends the header, made once per operation and sent again on every retry of it:
const key = crypto.randomUUID();await fetch("/activate", { method: "POST", body, headers: { "Idempotency-Key": key } });The header wins over the field when a request has both.
Two dispatches of one command
Section titled “Two dispatches of one command”A key names one command, so an action that dispatches the same command twice shares one key
between them: the second answers what the first did with the same payload, and conflicts with
another. Pass idempotencyKey yourself, which wins over the request’s key, derived with
idempotencyKeyFor from @bounda-dev/core:
const form = await request.formData();const key = request.headers.get("Idempotency-Key") ?? form.get("idempotencyKey");for (const [index, seat] of seats.entries()) { const keyed = typeof key === "string" ? { idempotencyKey: idempotencyKeyFor(key, `seat-${index}`) } : {}; await commands.reserveSeat(seat, keyed);}failure() answers a conflict with a 422 (errors from the domain).
On Cloudflare
Section titled “On Cloudflare”connect(stub) sends the option to the Durable Object with the command, where it works as above;
React Router on Cloudflare reads the header and the field the same way. The JSON API of
createWorker takes delay and correlationId from the query string and no key.
In a policy or a process
Section titled “In a policy or a process”The commands a policy or process dispatches get the same ids on every retry of the run, so a
scheduled one is stored once and a provider sees one key
(what the runtime promises); they rarely
need an idempotencyKey of their own. One they pass is recorded with the run’s unit of work and
answered from it on a later run.
Elsewhere
Section titled “Elsewhere”Stripe’s Idempotency-Key remembers the first
response for 24 hours and refuses the key with other parameters, which is where the lifetime and
the fingerprint come from; the IETF’s
Idempotency-Key header
draft names the header and the 422 for a mismatched payload.