You press Save once. The request reports a temporary failure, retries, and eventually succeeds. Two identical notes appear. Where did the second note come from?
Suppose a small notebook service creates one note per request. This JavaScript lab loses a response after the write and compares two clients against the same service.
Put the write and the failure on one timeline
Open the downloadable lab and read the transport before the retry loop. It calls the notebook, records the returned ID, and only then throws ResponseLost. The first attempt has already changed storage when the client receives its error.
| Step | Client sees | Notebook contains |
|---|---|---|
| First attempt writes | Waiting for a response | One note |
| Transport loses the response | An error | One note |
| Client sends another attempt | Waiting again | Depends on key and server lookup |
| Second response arrives | Success | One or two notes |
The failure is deliberately placed after the write. Moving it before the call would test a different case and hide this duplicate. The transport throws deterministically, without a network or clock.
Give the operation a key before entering the loop
The two clients differ only in where they generate the key:
function saveUnsafe(send, title, newKey) {
return createWithRetry(send, title, () => newKey());
}
function save(send, title, newKey) {
const key = newKey();
return createWithRetry(send, title, () => key);
}
In saveUnsafe, the callback generates a fresh key each time the loop sends. The service sees two different operations and creates two notes. In save, one key is captured before retrying, so both attempts carry the same identity and title.
The lab uses numbered strings to make that difference easy to inspect. They identify one logical Save, not an individual network attempt or the title itself. Two separate Saves with identical titles still receive different keys. Check the generator’s lifetime: a variable named “idempotency key” can still be recreated inside a retry wrapper.
Follow the key into the server lookup
A stable key works here because the service keeps a completed entry and checks it before appending a note. The deduplicate option, on by default, lets one test turn this lookup off:
if (deduplicate && completed.has(key)) {
const previous = completed.get(key);
if (previous.title !== title)
throw new Error('key reused with another title');
return previous.note;
}
const note = Object.freeze({ id: `note-${notes.length + 1}`, title });
notes.push(note);
completed.set(key, { title, note });
return note;
A repeated key with the same title returns the previously created note. A repeated key with a changed title throws before another write. The title is the entire request payload in this lab, so that comparison covers all of its input.
The entry stores the input and result. Check the early return: continuing into the append would still create a duplicate. The fixed test checks the returned object as well as the final note count. Another reproduction disables server deduplication: the stable client key still produces two notes.
Run the failures and compare stored results
Download retry-idempotency.mjs. With Node.js 22 or later, run it without installing dependencies:
node --test retry-idempotency.mjs
All six tests pass. The two named “reproduce” pass when the intentional defect occurs. createWithRetry permits two total attempts and retries only the simulated response-loss error; a title conflict stops after one attempt.
| Case | Sends | Notes stored | Caller result |
|---|---|---|---|
| New key on retry | 2 | 2 | Second note, note-2 |
| Same key, replay enabled | 2 | 1 | Original note, note-1 |
| Two separate Saves | 2 | 2 | Two distinct IDs |
| Changed title under an existing key | 1 additional | 1 total | Rejected |
| Both responses lost | 2 | 1 | Rejected after the second attempt |
| Same key, replay disabled | 2 | 2 | Second note |
The fifth case matters when reading error handling: the client ends with an error even though the note exists. The assertions inspect stored notes, attempt keys, and returned IDs, so a passing response check alone cannot conceal a duplicate.
Trace the same five places in an application
- Find the user action and decide which attempts belong to the same intended operation.
- Locate key creation and follow it through every retry wrapper and request argument.
- Open the write itself and mark failures that can occur after it completes.
- Follow the server lookup, input comparison, and return of an earlier result.
- Check the retry limit and the final error path against stored state.
Record source locations. If a wrapper retries a callback that creates the key, inspect that callback too. For a broader route-to-storage walk, see tracing an HTTP request through code.
Keep the memory example separate from durable guarantees
This service stores everything in a single-process Map and array. Its lookup and write are synchronous. It does not test database transactions, concurrent workers, restarts, or durable recovery, and it provides no production exactly-once guarantee.
A real implementation needs explicit decisions about key scope, payload comparison, retention, concurrent attempts, and how the write and replay record survive failure together. This client also forgets its key when Save ends: a later manual retry or reload needs a separate operation-lifetime design. Those questions require the actual storage and client code. Use the code-reading guide to keep the relevant callers and definitions together while following them.