Designing for the second click
Timeouts happen. Retries happen. Idempotency is how an API remembers what the user actually intended.
A user submits a request. The server completes it, but the response disappears on the way back. From the browser's perspective, the operation failed. From the database's perspective, it succeeded. The next click is not careless behaviour; it is a reasonable response to uncertainty. An API needs to account for both perspectives.
Identify the operation, not the attempt
Consider an export service. A person asks for one report and retries after a timeout. If every request creates a new export, the service can produce duplicate files, send duplicate emails, and charge twice for expensive processing. An idempotency key lets the client identify the same intended operation across multiple attempts.
The key should not silently turn different requests into the same action. Associate it with the authenticated scope and a fingerprint of the relevant input. If a caller reuses it with incompatible parameters, reject the conflict rather than returning an unrelated result. Stripe documents this parameter comparison in its own API; the exact retention and error semantics remain specific to each implementation.
Concurrency is the difficult part
A process-local dictionary is not a sufficient solution when two application instances receive the same key simultaneously. Both may observe “not found” and start the operation. The claim on a key needs an atomic boundary, often enforced with a database uniqueness constraint and a transaction.
A conceptual record could include the caller, key, request fingerprint, operation status, and result reference. One request wins the right to create the operation. Another can receive the completed result or a clearly documented in-progress response. Avoid promising that every duplicate immediately gets a finished answer when the original work may still be running.
A database transaction cannot cover everything
Suppose the operation calls an external email provider. Recording “sent” before the call risks losing the email if the process crashes. Recording it afterwards risks sending twice if the provider accepts the message and the process crashes before recording success.
This boundary deserves explicit design. Use a provider's idempotency capability where available, or model the work as a durable delivery intent with reconciliation. A local transaction alone does not make a remote side effect exactly once. Be precise about what the service can guarantee and what remains uncertain.
Test the ambiguous moments
Test two concurrent submissions with the same key, a retry after success, a retry with different input, and a failure between each important state change. Also decide how long operation records remain available. After expiry, the same key may no longer protect against duplication; clients need to understand that contract.
The visible result is modest: a button that behaves sensibly when the network does not. The engineering underneath is valuable precisely because the user should not have to think about it.
Further reading: Stripe: Idempotent requests and PostgreSQL: Unique constraints.