A customer clicks "Pay now". Nothing happens for three seconds, so they click again. Or their phone loses signal for a moment and the app quietly retries the request. Or your load balancer times out and sends the request again.
Your server received the same order twice, and it did exactly what you told it to do: it created two orders.
This is not a rare edge case. It is the default behavior of every POST endpoint you have ever written.
The Endpoint That Looks Fine
app.post('/api/orders', requireAuth, async (req, res) => {
const order = await createOrder(req.user.id, req.body);
await chargeCard(order);
res.status(201).json({ orderId: order.id });
});
Nothing is wrong with this code in isolation. The problem is what it cannot know: whether this request is new, or a repeat of one that already succeeded.
Why This Is Hard to Catch
- You test with one request at a time. The bug needs two requests with the same intent, close together.
- Timeouts are ambiguous. When a client times out, it does not know if the server never got the request, or did the work and the response got lost. Retrying is the only safe-looking choice, and it is the one that duplicates.
- Disabling the button is not enough. It helps with double clicks but not with network retries, mobile apps, or another service calling your API.
The Idea: Same Key, Same Result
An idempotent operation is one where doing it twice has the same effect as doing it once. POST is not idempotent by default, but you can make it behave that way:
- The client generates a unique idempotency key for each intent and sends it in a header.
- The server remembers the key together with the result.
- If the same key arrives again, the server returns the saved result and does not do the work again.
Payment providers such as Stripe use exactly this pattern with an Idempotency-Key header.
A Working Version With Express and MongoDB
First, a unique index, plus an expiry so old keys clean themselves up:
await keys.createIndex({ userId: 1, key: 1 }, { unique: true });
await keys.createIndex({ createdAt: 1 }, { expireAfterSeconds: 86400 });
Then the endpoint:
app.post('/api/orders', requireAuth, async (req, res) => {
const key = req.get('Idempotency-Key');
if (!key) return res.status(400).json({ error: 'Idempotency-Key header required' });
const userId = req.user.id;
const bodyHash = hash(stableStringify(req.body));
// 1. Try to claim the key. Only one request can win this insert.
try {
await keys.insertOne({ userId, key, bodyHash, status: 'processing', createdAt: new Date() });
} catch (err) {
if (err.code !== 11000) throw err; // 11000 = duplicate key
const saved = await keys.findOne({ userId, key });
if (saved.bodyHash !== bodyHash) {
return res.status(422).json({ error: 'Key was used with a different request' });
}
if (saved.status === 'processing') {
return res.status(409).json({ error: 'Request is still being processed' });
}
return res.status(saved.statusCode).json(saved.response); // replay
}
// 2. We own the key. Do the work once.
try {
const order = await createOrder(userId, req.body);
await chargeCard(order);
const response = { orderId: order.id };
await keys.updateOne(
{ userId, key },
{ $set: { status: 'done', statusCode: 201, response } }
);
return res.status(201).json(response);
} catch (err) {
await keys.deleteOne({ userId, key }); // let the client retry
throw err;
}
});
The Race Condition Most Examples Miss
A common first attempt is:
const existing = await keys.findOne({ userId, key });
if (existing) return res.json(existing.response);
// ... do the work ...
await keys.insertOne({ userId, key, response });
Two simultaneous requests both run findOne, both see nothing, and both do the work. You added a check and still got a duplicate.
The fix is in the version above: claim the key first with an insert that can only succeed once, and let the unique index decide the winner. The database settles the race, not your code.
Four Details That Matter
1. Scope keys per user. Two users who happen to generate the same key must not see each other's responses. That is why the index is on { userId, key }.
2. Compare the request body. If the same key arrives with a different body, that is a bug or an attack, not a retry. Return an error instead of replaying a response that does not match the request. Use a stable stringify (sorted keys) for the hash, or two identical bodies with different property order will hash differently.
3. Generate the key per intent, not per attempt. On the client, create the key when the user starts the action and reuse it for every retry of that action. A new key per retry brings the duplicate right back.
const key = crypto.randomUUID(); // once, when the checkout starts
await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
body: JSON.stringify(cart),
});
4. Pass the key on to your payment provider too. If your server crashes after charging the card but before saving the result, your own table cannot help. A key sent to the provider protects that gap, and the expiry index clears any stuck processing record.
The Rule
Any request that creates something or moves money must be safe to send twice. Do not rely on disabled buttons or "the network is reliable". Give the client a key, claim it atomically on the server, and replay the saved result on a repeat.
Quick audit: list your POST endpoints that create orders, payments, invoices, emails or bookings. For each, ask what happens if the request arrives twice, a second apart. If the answer is "two of them", you have found your next ticket.
If you work with MongoDB, my MongoDB and Mongoose patterns guide has more on using the database to enforce rules, and you can browse my Next.js templates on pixelanas.com.
Get the templates: https://pixelanas.gumroad.com
Have you ever shipped a double-charge or a duplicate-order bug? Tell me how you found out, in the comments.
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)