The first API request passed its test. Then the client lost the response, tried again, and created the same order twice. This can happen after a timeout, refresh, double click, or automatic retry. The happy path stays green, while the operation that needed protection has no proof.
Idempotency does not mean every call returns the same response. It means repeating the same intent should not repeat the effect. To test it, count the observable effect and exercise repeated, concurrent, and incompatible requests. Testing webhooks with duplicates and retries helps when a provider is the source; this article focuses on an API you control.

Short answer
- The first request should execute the effect once.
- The same request after completion should return the stored result without executing again.
- A duplicate that arrives while the first request is running needs an explicit result, such as
409.- The same key with a different payload should be rejected, such as with
422, when the contract compares request fingerprints.
What does an idempotency test need to prove?
A POST request can create a new effect each time it arrives. The Idempotency-Key
header lets a server recognize a repeated attempt, but the contract belongs to the
endpoint. MDN's Idempotency-Key header reference separates the client's job, reusing the key on a retry, from the server's job, documenting the rule.
The test should follow the request to the point where the effect happens. Checking only that the second request found a key is not enough. A bug can detect the duplicate and still let the handler continue into post-processing. A recent Mastodon issue shows this kind of failure: the duplicate was recognized, but the later flow produced an error.
| Case | What to simulate | What to verify |
|---|---|---|
| First request | new key and valid payload | effect executes and returns success |
| Replay | same key and payload after completion | same response, no second effect |
| Concurrency | same key while the first request waits | conflict or waiting response, according to the contract |
| Key reuse | same key with a different payload | rejection before the effect |
| Recoverable failure | effect fails before completion | documented rule for releasing or retaining the key |
This matrix keeps two questions separate. First, does the endpoint recognize the same intent? Second, does the protection reach the effect, the stored response, and the error paths?
How do you build a small handler to test?
The example below uses a Map to make the contract visible. It is not a production
solution for multiple processes. The goal is a deterministic fixture that counts
how many times the real operation was called.
class IdempotencyStore {
#entries = new Map();
begin(key, fingerprint) {
const existing = this.#entries.get(key);
if (!existing) {
this.#entries.set(key, { fingerprint, state: 'running' });
return { kind: 'new' };
}
if (existing.fingerprint !== fingerprint) {
return { kind: 'payload-mismatch' };
}
if (existing.state === 'running') return { kind: 'in-flight' };
return { kind: 'replay', response: existing.response };
}
complete(key, response) {
const entry = this.#entries.get(key);
if (!entry) throw new Error('missing idempotency entry');
this.#entries.set(key, { ...entry, state: 'complete', response });
}
}
function createHandler(store, perform) {
return async function handle({ key, body }) {
if (!key) return { status: 400, body: { error: 'missing_key' } };
const fingerprint = JSON.stringify(body);
const decision = store.begin(key, fingerprint);
if (decision.kind === 'payload-mismatch') {
return { status: 422, body: { error: 'key_reused_with_other_payload' } };
}
if (decision.kind === 'in-flight') {
return { status: 409, body: { error: 'request_in_progress' } };
}
if (decision.kind === 'replay') return decision.response;
const result = await perform(body);
const response = { status: 201, body: result };
store.complete(key, response);
return response;
};
}
Four decisions matter in this code. The entry is recorded as running before the
effect starts. A replay returns the completed response. A changed payload never
reaches the effect. The effect becomes complete only after the response is ready.
Stripe's idempotent request reference describes a similar boundary: results are saved after endpoint execution begins, while concurrent conflicts do not need to save an idempotent result.
JSON.stringify is only a simple fingerprint for the fixture. In a real API, the
fingerprint must be stable and follow the endpoint contract. Property order,
ignored fields, normalization, and payload limits cannot remain implicit.
How do you test replay without duplicating the effect?
Start with the case people often miss: the first request finished, but the client
does not know it. Call the handler twice with the same key and body. The most
important assertion is not the second status. It is the effect counter, which must
remain at 1.
import assert from 'node:assert/strict';
import { test } from 'node:test';
test('replays the completed result without repeating the effect', async () => {
let executions = 0;
const handle = createHandler(new IdempotencyStore(), async (body) => {
executions += 1;
return { id: 'order-1', ...body };
});
const first = await handle({ key: 'request-1', body: { item: 'book' } });
const replay = await handle({ key: 'request-1', body: { item: 'book' } });
assert.deepEqual(replay, first);
assert.equal(executions, 1);
});
The built-in node:test runner waits for an async test function before it finishes,
as described in the Node.js test runner documentation. Save the handler and test in an .mjs file, keep the imports shown, and run:
node --test idempotency.test.mjs
This case covers replay after completion. It does not prove that reserving the key and creating the resource are atomic in a database. That difference belongs in the test name and in the documented limit.
How do you simulate two concurrent requests?
For concurrency, the first effect must stay pending. A manual Promise lets the
second request arrive before completion without depending on setTimeout or an
accidental machine-level race.
test('returns a conflict while the first effect is running', async () => {
let executions = 0;
let release;
const pending = new Promise((resolve) => { release = resolve; });
const handle = createHandler(new IdempotencyStore(), async (body) => {
executions += 1;
await pending;
return { id: 'order-2', ...body };
});
const firstPromise = handle({ key: 'request-2', body: { item: 'pen' } });
await Promise.resolve();
const concurrent = await handle({ key: 'request-2', body: { item: 'pen' } });
assert.equal(concurrent.status, 409);
release();
assert.equal((await firstPromise).status, 201);
assert.equal(executions, 1);
});
await Promise.resolve() yields to the handler without adding a clock-based wait.
You can use another synchronization mechanism, but the intention should remain
clear: the second call occurs while the state is still running.
The 409 status is a contract choice, not a universal requirement. MDN lists a conflict for a request with the same key still being processed, but the body, headers, and retry policy remain service decisions.
Why test the same key with a different payload?
A key identifies one intention. It is not a free pass to reuse the same value for
any operation. If request-3 created a pencil, the same value should not create a
ruler. Without this check, a delayed retry can receive or return the result of a
different operation.
test('rejects the same key when the payload changes', async () => {
const handle = createHandler(new IdempotencyStore(), async (body) => ({
id: 'order-3',
...body,
}));
await handle({ key: 'request-3', body: { item: 'pencil' } });
const mismatch = await handle({ key: 'request-3', body: { item: 'ruler' } });
assert.equal(mismatch.status, 422);
});
This case follows the idea of a payload fingerprint. MDN describes storing that fingerprint and returning an error when the same key arrives with another one. The IETF HTTPAPI working draft makes a similar distinction between replay, a concurrent conflict, and a key used with an incompatible payload, but it is still a draft rather than a final standard.
Which test layer should contain this proof?
The in-memory fixture is a fast test of the handler protocol. It confirms the order of decisions, each response path, and the effect counter. That is useful, but it does not replace a proof at the boundary that protects the resource.
Use complementary layers:
- Unit: test payload fingerprinting, the
runningtocompletetransition, and handler responses with a substitute store. - Integration: use the real database, cache, or lock and send two requests close enough to compete for the same key. Verify one observable write.
- Provider contract: if an external API offers its own key, use its test environment and confirm replay, conflict, and expiry against its documentation. Do not turn Stripe's behavior into a rule for every API.
The unit versus integration testing comparison in Node.js helps choose the boundary. The question is not how many tests should be unit tests. It is which component must remain real to prove that two requests do not repeat the effect.
What the fixture cannot prove
The demonstration Map is deliberately small. It does not coordinate two process
instances, survive a restart, or expire keys. It also does not make the sequence
between reserving the key, running the operation, and persisting the result atomic.
A database or distributed cache needs a reservation operation with the right
atomicity for its environment.
Define what happens when the effect fails. Some APIs remove an incomplete reservation and allow a retry. Others store the error so the same intent receives the same result. Do not choose based on test convenience. Choose the semantics the client can understand and document how long the key is retained.
Testing JavaScript retry logic without waiting covers time and backoff. This article has a different focus: a retry may happen after a lost response, but the effect must remain one occurrence.
Frequently asked questions
Does calling the same request twice prove idempotency?
No. It proves only a replay path if the second request occurs after completion. Add a concurrent call, a key with a different payload, and an effect counter. If the protection depends on a database or cache, repeat the proof at that real boundary.
Does a duplicate have to return 200?
No. The contract can return the original response, a conflict while the operation is running, or another documented result. The client must know whether to reuse, wait, fix the payload, or start a new intention.
Can I delete the key when the effect fails?
You can if the contract treats the failure as an attempt that may be retried. Another option is storing the error response so the same intent receives the same result. Test the choice, because deleting too early can allow an unintended second execution.
Is a Map enough for production?
No. It is enough to test logic within one process. Production needs persistence, expiry, coordination between workers, and a defined relationship between the key, payload fingerprint, effect, and stored response.
Conclusion
The useful test does not stop at the first 201. It repeats the intent after
completion, keeps one call pending to test concurrency, and changes the payload to
check the key contract. Count the observable effect in every path.
Then repeat the same cases with the real storage. The in-memory fixture shows whether the handler chooses the right path. The integration test shows whether the boundary that prevents duplication still holds with databases, caches, workers, and restarts.
Sources consulted
- MDN,
Idempotency-Keyheader, retrieved 2026-09-22. - Stripe API Reference, Idempotent requests, retrieved 2026-09-22.
- Node.js, Test runner, retrieved 2026-09-22.
- IETF HTTPAPI, Idempotency-Key working draft, retrieved 2026-09-22.
- Mastodon issue #40399, retrieved 2026-09-22. Used as an observed failure example, not normative documentation.