A warehouse application I own was occasionally writing the same inventory movement twice. The reports all said the same thing: the worker pressed the button twice. Every instinct in interface design says to fix that with a disabled button and a spinner.
That would have hidden the bug for a while and made it much harder to find later.
What was actually happening
Warehouse Wi-Fi is not office Wi-Fi. It drops behind racking, it hands off between access points while someone walks, and it does this in the middle of requests. The sequence was:
- The tablet sends a write.
- The server receives it and commits it.
- The response never arrives back at the tablet.
- The tablet waits, times out, and reports a failure.
- The worker, correctly, retries.
Nobody did anything wrong. The worker retried a failure that the interface reported. The interface reported a failure it had no way to distinguish from a success. The only incorrect belief in the whole chain belonged to the client, and it was a belief about silence.
Silence is not failure
This is the part that is easy to say and hard to internalise. When a request times out, you know exactly one thing: you did not get an answer. You do not know whether the server received the request, whether it committed, or whether it is still working on it. Three very different states, one observation.
A timeout tells you about your connection. It tells you nothing about the other machine's state.
Any system that treats a timeout as a failed write will eventually write twice. Not because of a race condition you can reason your way out of, but because the information needed to make that call never arrives.
Give the intent a key, not the attempt
The fix is to make the write safe to repeat, and the mechanism is a key that identifies the intent rather than the attempt.
The distinction matters. If the client generates a fresh identifier each time it sends, every retry looks like new work and you have gained nothing. The identifier has to be minted when the user decides to do the thing, held across retries, and discarded only when the operation is genuinely finished.
// minted once, when the user commits to the action
const intentKey = crypto.randomUUID();
async function submit(payload, attempt = 0) {
try {
return await post("/fulfil", { intentKey, ...payload });
} catch (err) {
if (attempt < 4 && isTransient(err)) {
await backoff(attempt);
return submit(payload, attempt + 1); // same key, deliberately
}
throw err;
}
}
On the server, the key is resolved against stored state before anything is written. If that key already produced a record, return the original result. The client gets the same answer it would have got the first time, and the user sees a success instead of an error they have to think about.
Where to store the key depends on your platform. The important property is that the check and the write cannot interleave, so the natural home is the same record or the same transaction as the thing being created, not a cache beside it.
Then separate your timeout budgets
There is a second change, and in my case it removed most of the retries before idempotency ever had to catch them.
Reads and writes were sharing one timeout value, which is a very common default and almost always wrong. A read that takes too long is a real problem and surfacing it quickly is correct. A write that takes too long is usually a slow commit that is going to succeed, and cancelling it client side converts a slow success into a reported failure.
Splitting the two gave the write path a budget that matched what a write actually costs on a busy ERP. The duplicates stopped before the idempotency layer was doing much work, which is the right order: make the common case not generate retries, and make retries harmless when they happen anyway.
The short version
- A timeout is missing information, not a failure.
- Key the intent, not the attempt, and hold the key across retries.
- Resolve the key server side, in the same transaction as the write.
- Give reads and writes separate timeout budgets.
- Add this on day one. Retrofitting means reconciling records that were already written twice.