Idempotency keys and safe retries
A retry is not a new request, but your API cannot tell the difference unless the client says so. Give it a key, store the result against that key in the same transaction as the effect, and return the stored result the second time.
You collect a takeaway with a numbered ticket. Show the same number again and you get the order you already placed, not a second one. Without a number, every time you ask looks like a brand new order and the kitchen starts cooking again.
The retry is not hypothetical. A phone loses signal after the request left and before the response came back; a load balancer times out at thirty seconds while the write takes thirty-one; a queue consumer crashes between doing the work and acknowledging it. In every one of those cases — the network failures the distributed-systems path exists to work through — the client has no way to know whether the work happened, and the only safe thing it can do is ask again.
What the key has to promise#
The client generates the key, once, before the first attempt, and reuses it for every retry of that intention. That is the part teams get wrong: a key generated per HTTP call is not an idempotency key, it is a request id, and it protects nothing.
The server stores the key together with a hash of the request body. If the same key arrives with a different body, that is not a retry — it is a bug or an attack, and the honest response is a 422. The two obvious alternatives are both worse: replaying the first request’s result answers a question nobody asked, and doing the new work under the old key discards the protection the key was there to provide.
The window matters too. Keys cannot be kept forever, and a client retrying after your retention window has passed gets the work done a second time. Twenty-four hours is a common answer; whatever you choose, it belongs in the documentation, because it is part of the contract.
Where the record has to live#
In the same database, in the same transaction, as the effect it is protecting. Not in Redis beside the database, not in a middleware that writes after the handler returns — because every one of those introduces a moment where the effect exists and the key does not, and that moment is exactly the one the retry lands in. It is a transaction-boundary question before it is an idempotency question: state and the record of that state change commit together, or neither does. That ordering is why the architecture path reaches boundaries before it reaches this article.
-- the key and the effect commit together, or not at all
BEGIN;
-- RETURNING yields a row only if THIS statement did the insert.
-- One row: this request owns the work. No row: someone else does.
INSERT INTO idempotency (key, request_hash, status)
VALUES ('k_9f2c', '8ab1…', 'in_flight')
ON CONFLICT (key) DO NOTHING
RETURNING key;
-- Only the request that won the claim runs the next two statements.
-- (Not one statement with WITH: in Postgres a data-modifying CTE and the
-- main query share one snapshot, so an UPDATE in the same statement would
-- never see the row the INSERT just claimed.)
INSERT INTO orders (customer, item_id) VALUES (4711, 88);
UPDATE idempotency SET status = 'succeeded', response = '{…}'
WHERE key = 'k_9f2c';
COMMIT;
-- a replay claims nothing, orders nothing, and reads the stored result
SELECT status, response FROM idempotency WHERE key = 'k_9f2c'; The empty RETURNING is the whole lock: it is how a request learns it lost the race. Whichever one inserted the row owns the work, and only that request runs the statements below — so a replay reaches the SELECT having written nothing at all, and returns what the winner stored. Keep the claim and the follow-up as separate statements in the one transaction: folded into a single statement with WITH, the final UPDATE cannot see the row the claim inserted, and the key stays in flight forever.
The four ways it still breaks#
The key is generated per attempt
Then every retry is a new intention as far as the server is concerned, and you have built an audit trail of duplicate work rather than a defence against it.
The in-flight state is missing
Two attempts arrive concurrently, both find no key, both do the work. Without a row that says someone is already doing this, the window is as wide as your latency.
Failures are cached as results
A transient 503 stored against the key means the client can never successfully retry. Store terminal outcomes; let retryable failures release the key.
The side effects are outside the transaction
The write is idempotent, and then the handler also sends an email. The second attempt replays the stored response and sends a second email, because the email was never part of the record.
What to return on a replay#
The same status code and the same body as the first time, byte for byte, because the client is entitled to assume it is seeing the original outcome. Adding a header that says the response was replayed is useful for debugging and harmless to clients that ignore it.
IF YOU REMEMBER ONE THING
An idempotency key is not a cache key. It is a claim on a piece of work, and it has to be written down in the same breath as the work itself.
A key makes a retry safe. It does not make the retry someone else’s problem, which is the usual reason a queue gets added — and most of the time the queue is the wrong answer.
Questions people also ask
6 QUESTIONSWhere should the idempotency key come from?
The client, generated once per intention, before the first attempt, and reused unchanged for every retry of that same intention. A UUID is fine. A key generated per HTTP call is not an idempotency key — it is a request id, and it protects nothing.
How long should I keep keys?
Long enough to outlast the slowest client's own retry budget, which is the number to ask for rather than guess at. A mobile client that retries over 48 hours of patchy signal is not served by a 24-hour window, and the mismatch shows up as duplicate work nobody can reproduce. Publish the window, and treat shortening it as a breaking change.
Can I store the key in Redis instead of the database?
Not safely, if the effect it protects lives in the database. The key and the effect have to commit together; two stores means a window where the effect exists and the key does not, and that is precisely the window a retry lands in.
What should the API return on a replayed request?
Whatever the original returned, byte for byte, which means you have to have stored it. The awkward case is a request still in flight when the retry arrives: you have a key, a claim and no result yet. Answering 409 and letting the client retry again is honest; inventing a success is not, and neither is holding the connection open until the first one finishes.
What if the same key arrives with a different body?
Reject it. Store a hash of the request body beside the key so you can tell — without it you cannot detect the case at all, and you will silently serve the first request's result for a second request that asked for something else. That is the failure mode with no log line.
Does idempotency remove the need for retries?
It does the opposite: it makes retries safe, so clients can and should retry. The harder case is a client you do not control — a third-party webhook sender that retries on its own schedule and sends no key at all. There you have to derive one from something stable in the payload, usually the provider's own event id, and that decision belongs in writing next to the endpoint.