
If your application takes payments, duplicate charges are one of the most damaging bugs you can ship. The customer pays once but gets charged twice, or a single purchase turns into two or three identical orders in your system. The support tickets arrive fast, the refunds eat your time, and the trust you spent months building takes a hit in minutes. The frustrating part is that the code usually looks correct, so the bug stays hidden until real traffic finds it.
This guide explains, in simple terms, why duplicate payments happen and how to prevent duplicate payments for good. It is written for developers who want working code, but the early sections are plain enough for a founder or business owner to follow, because the why matters as much as the how. As a real world note, this exact bug has taken down the checkout of busy stores during a flash sale, charging dozens of customers twice before anyone noticed, so it is well worth getting right.
Most teams assume a double charge is a rare freak event. It is not. There are three everyday situations that each send the same action twice, and a basic checkout cannot tell them apart.
On a slow connection, a customer taps Pay, sees nothing for a few seconds, and taps again. Sometimes the browser itself resends a request that looked like it failed but actually went through. Either way, your server receives two charge requests for one purchase.
Payment providers confirm a payment by sending your server a message called a webhook. To stay reliable, they resend it if they do not get a quick, clear reply. If your handler creates an order every time a webhook lands, those resends create duplicate orders.
Under load, the same action can hit your server twice within milliseconds. Both requests check whether the work is done, both see no, and both proceed. This is a race condition, and it is the kind of bug that only appears when traffic is high, which is why it slips past testing.
Before the real fix, it helps to know what does not work, because these are the patches most teams reach for first.
Disabling the Pay button after one click feels like a solution, but it does nothing for browser resends, webhook retries, or requests already travelling to the server. Checking for a recent matching order before creating a new one seems safer, yet the check and the create are two separate steps, so two requests can still slip between them. The takeaway is simple. You cannot solve this in the browser or with a loose check. The server itself has to refuse to do the same work twice.

Everything rests on one idea. Give every payment attempt a unique label, and make your server act on that label only once. If the same label arrives again, the server does not redo the work. It returns the result it produced the first time. That label is an idempotency key, and idempotency simply means that running an action many times has the same effect as running it once.
Here is the trap to avoid, so the fix makes sense.
// Naive checkout: no protection against repeats
app.post("/api/checkout", async (req, res) => {
const { cartId, amount } = req.body;
const charge = await payments.charge({ amount }); // money moves here
const order = await db.orders.create({ cartId, chargeId: charge.id });
res.json({ orderId: order.id });
});
This handler has no memory. Every call charges the card and creates an order, however many times the same purchase arrives. Now let us build it properly, one step at a time.

The browser creates one unique key when the customer begins a checkout, and sends that same key on every retry of that attempt. A fresh purchase gets a fresh key. A retry of the same purchase reuses the key.
// The browser creates one key per checkout attempt and reuses it on retry
const idempotencyKey = crypto.randomUUID(); // made once, before the first try
await fetch("/api/checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey // same key on every retry of THIS attempt
},
body: JSON.stringify({ cartId, amount })
});
On the server, keep a small table that remembers every key you have seen, along with the result you produced. The key is the primary key of the table, so the database itself refuses to store the same key twice.
-- Each processed request is recorded once, by its key
CREATE TABLE idempotency_records (
key TEXT PRIMARY KEY, -- the Idempotency-Key from the request
status TEXT NOT NULL, -- "in_progress" or "done"
response JSONB, -- the saved result to replay on a repeat
created_at TIMESTAMPTZ DEFAULT now()
);
Now the core logic. When a request arrives, try to insert its key first. If the insert succeeds, this is the first time, so do the real work and save the result. If the insert hits a conflict, you have seen this key before, so skip the work and return the saved result. Wrapping it in a transaction keeps everything consistent.
app.post("/api/checkout", async (req, res) => {
const key = req.header("Idempotency-Key");
if (!key) return res.status(400).json({ error: "Missing Idempotency-Key" });
const client = await pool.connect();
try {
await client.query("BEGIN");
// 1. Try to claim this key. If it already exists, we have seen it before.
const claim = await client.query(
`INSERT INTO idempotency_records (key, status)
VALUES ($1, 'in_progress')
ON CONFLICT (key) DO NOTHING
RETURNING key`,
[key]
);
if (claim.rowCount === 0) {
// Key already exists: return the saved result instead of charging again
await client.query("ROLLBACK");
const prev = await pool.query(
`SELECT response FROM idempotency_records WHERE key = $1`,
[key]
);
return res.json(prev.rows[0].response ?? { status: "processing" });
}
// 2. First time we have seen this key, so do the real work exactly once
const { cartId, amount } = req.body;
const charge = await payments.charge({ amount, idempotencyKey: key });
const order = await client.query(
`INSERT INTO orders (cart_id, charge_id) VALUES ($1, $2) RETURNING id`,
[cartId, charge.id]
);
const response = { orderId: order.rows[0].id };
// 3. Save the result so any later retry can replay it
await client.query(
`UPDATE idempotency_records SET status = 'done', response = $2 WHERE key = $1`,
[key, response]
);
await client.query("COMMIT");
res.json(response);
} catch (err) {
await client.query("ROLLBACK");
res.status(500).json({ error: "Checkout failed, please retry" });
} finally {
client.release();
}
});
Pass the same key to your payment provider's own charge call as well. Good providers support an idempotency key too, so even the charge itself cannot run twice. That gives you protection at both layers.
The customer side is now safe, but the provider's webhooks can still arrive more than once. Protect them the same way, by recording each event's unique id and ignoring any event you have already handled.
// Payment providers retry webhooks, so dedupe on the event id
app.post("/api/webhooks", async (req, res) => {
const event = verifySignature(req); // always confirm it truly came from the provider
// Record the event id once. If it is already there, we have handled it.
const result = await pool.query(
`INSERT INTO processed_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id`,
[event.id]
);
if (result.rowCount === 0) {
return res.sendStatus(200); // already handled, acknowledge and stop
}
await handleEvent(event); // safe to run, because it runs only once per event
res.sendStatus(200);
});
Always verify the webhook signature first, so you only act on events that truly came from your provider.
The insert first pattern already handles most concurrency, because the database allows only one winner per key. For the rare case where two identical requests arrive at the exact same instant and you want the second one to wait and then return the real result, a short lock on the key does the job.
// For two identical requests at the same instant, lock on the key
// so the second one waits for the first to finish.
async function withKeyLock(client, key, fn) {
// hashtext turns the key into a number for the advisory lock
await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [key]);
return fn(); // the lock releases automatically when the transaction ends
}
Wrap the claim and the work inside this lock. The second request pauses until the first finishes, then sees the saved result and returns it. The lock releases on its own when the transaction ends, so there is nothing to clean up.
A few small details turn this from correct into reliable.
Make sure a repeated request gets the exact same response as the original, including the same order id. That way the customer's app behaves consistently whether it is the first try or the fifth, and there is never a confusing second order id.
Keys do not need to live forever. A small scheduled job that removes records older than a day or two keeps the table fast without losing any protection that matters.
Once this is in place, duplicate charges and duplicate orders stop completely, even if you deliberately hammer the checkout with repeated and simultaneous requests in testing. The system now follows one clear rule: one key, one outcome. Debugging gets easier too, because every payment attempt leaves a clean record of what happened and when. Better still, the same pattern works for any sensitive action, so you can reuse it for refunds, confirmation emails, and account changes, closing off a whole class of "it happened twice" bugs at once.
The business payoff is just as real. Fewer duplicate charges means fewer refunds, fewer angry tickets, and far less damage to customer trust. For a store running a big sale, that is the difference between a record day and a public mess.
Assume that any request can arrive more than once, because under real load it will. Design every sensitive action so it runs exactly once, give each attempt an idempotency key, and let your database enforce the uniqueness for you. Do that, and you prevent duplicate payments long before they ever reach a customer's statement.
If you're planning to build intelligent applications, How I Shipped Production-Ready AI Agents for a Client shares real-world lessons, architectural decisions, and deployment strategies that helped move AI from prototype to production.
Interested in using AI for operational excellence? How I Built an AI-Assisted Log Analysis System to Catch Production Issues Before Users Did breaks down a practical approach to monitoring, anomaly detection, and proactive issue resolution.
Before investing heavily in automation, read Why Most AI Automation Pipelines Break in Production - The AI Workflows with n8n and OpenAI Architecture That Actually Works to understand the common failure points and the architecture patterns that improve reliability.
Performance problems often hide in plain sight. How a Hidden N+1 Query Slowed API by 6x and the Exact Steps I Used to Fix It walks through a real debugging journey, showing how small database inefficiencies can create major scalability issues.
If you're building something complex and want a second brain before things get expensive — let's talk.

The API wasn’t crashing. Nothing looked broken. But production response times quietly became six times slower. This is a real-world breakdown of how a hidden N+1 query slipped through reviews, how I proved it in Laravel, and the exact steps that fixed it permanently.

Logs were there. Alerts were there. Incidents still slipped through. This guide explains how I combined traditional logging with AI-driven pattern analysis to proactively detect production issues and reduce firefighting.

OFFSET pagination broke our API at scale, causing slow queries and latency spikes. Learn how cursor pagination fixed performance without breaking clients.