TL;DR: When concurrent retries of the same order attempt both pass a naive “lookup then insert” path, inventory can be deducted twice—not because two customers raced, but because the server treated replays as new orders. Fix that with a stable idempotency key the server stores and enforces (via a DB unique constraint), explicit processing/confirmed/rejected/unknown states, and atomic order + inventory updates. Separate problem: two different customers racing for the last unit needs conditional inventory updates or locks—and event consumers still need idempotency because messages can redeliver.

Suppose an order API looks like this:

1. Look up whether the same order already exists.
2. If not, create the order.
3. Deduct inventory.

It works fine most of the time. Then payment completes and the response drops. The user thinks the order failed and taps again. The app may retry the same request after a timeout. If two requests pass the initial lookup almost at the same time, both can decide no order exists yet. Insertion that relies on a prior read without a uniqueness constraint does not win that race.[1]

The outcome is not just two orders. Inventory is deducted twice.

If you shrink the problem to “the user double-clicked,” the fix stops at disabling the button. In practice, identifying whether attempts belong to the same order, ensuring exactly one order is confirmed, deducting inventory once, and preventing downstream systems from running the same work again are separate responsibilities.

Retries of the same order are not the same as different orders#

Start by separating two scenarios.

  • Retransmission of the same order attempt: Response loss, timeouts, app restarts, or duplicate clicks can deliver one logical order request to the server multiple times. Idempotency is the API property of handling such retries without duplicate side effects.[2][5]
  • Competition between distinct valid orders: Two customers order the last unit at the same time. Here you need to control concurrent updates to the inventory row, not reuse a request key.[4]

Both look like inventory problems, but they are not the same failure mode. A retry of the same order must be processed once; different orders must compete fairly without overselling.

An idempotency key asks: “Have we already handled this same order attempt?” Locks and conditional updates ask: “Is it safe to change this inventory right now?”

One does not replace the other.

The same order attempt needs an identifier#

POST /orders is a mutating API that easily creates two orders when the same request is sent twice. When the user taps order, the client should mint an idempotencyKey and send the same key for every retry of that attempt.

An API can use a stable Idempotency-Key header so retries of POST or PATCH do not repeat side effects—provided the server stores and enforces the key; support is API-specific and the header is non-standard.[5] The same key is only for the same retry chain; a new order attempt must use a new key.[5]

Without that distinction, teams often invent rules like:

If the same customer orders the same product again within five minutes, treat it as a duplicate.

That sounds safe but blocks legitimate orders. People buy the same product twice. They may split checkout or order again for a gift. Customer + product + a short window guesses at duplication; it does not prove intent.

An idempotency key names the attempt the user started. Storing the key alone is not enough: you must reject a new payload under the same key (e.g. different quantity or address). Keep a fingerprint of core fields and return an error when the same key arrives with a different request.[5]

processing is neither failure nor success#

For requests under the same key, you need at least four outcomes:

StateMeaningResponse to a second request
processingFirst request still in flightDo not create a new order; report in progress
confirmedOrder and inventory reservation/deduction are finalReturn the existing order result
rejectedOut of stock or rule violationReturn the same rejection
unknownExternal payment/communication error; outcome not finalizedDo not create a new order; offer a path to resolve status

If you blur these states, the second request may fabricate success—or create another order.

A timeout is not immediate failure. The server may have finished the order and inventory deduction but lost the response. An external payment call may have reached the processor with an unclear outcome. Stripe advises retrying with the same key and parameters after network errors, and reconciling or confirming the original result before starting new work when the outcome is unclear.[7]

So processing and unknown may look awkward in the UI, but they must not be hidden. The user should see something like “Checking whether your order was accepted” and be able to poll order status. The system must not create a new order in the meantime.[2][7]

Check-then-insert is not a guarantee under concurrency#

Even with an idempotency key, this implementation is unsafe:

SELECT * FROM orders WHERE idempotency_key = :key

-- if no row
INSERT INTO orders (...)

Two transactions can both see no row. “We observed no duplicate” is a momentary fact, not a promise for the next instant.

The rule “exactly one order” must be enforced by the database.

CREATE UNIQUE INDEX uq_order_request
ON orders (customer_id, idempotency_key);

Let the database decide conflicts:

INSERT INTO orders (customer_id, idempotency_key, request_hash, status)
VALUES (:customer_id, :key, :request_hash, 'processing')
ON CONFLICT (customer_id, idempotency_key) DO NOTHING;

Only the insert that succeeds proceeds with order handling. On conflict, read the existing row and return processing, confirmed, rejected, or unknown as appropriate.

PostgreSQL documents ON CONFLICT for specifying behavior on uniqueness violations.[1] Here the constraint’s job is clear: prevent two confirmed orders for the same request key.[1]

Do not put this index on customer_id + product_id. That enforces a different product policy—“you cannot buy the same product twice”—not idempotency.

Inventory deduction is a separate integrity rule#

Now two different customers order the last unit. Their idempotency keys differ, so you must not treat one as a duplicate of the other.

Inventory is safer when the database judges current stock and deduction conditions in one step, rather than reading in application code and writing later.

UPDATE inventory
SET available_quantity = available_quantity - :quantity
WHERE product_id = :product_id
  AND available_quantity >= :quantity;

One affected row means success; zero means insufficient stock. This conditional update re-evaluates against current state at write time.[4]

Pessimistic or optimistic locking are also options. Complex rules—reservations, multi-line orders, quantity math—may require locking a row and processing serially. PostgreSQL notes explicit locking when MVCC alone is not enough.[4]

Locks alone do not stop re-execution of the same order attempt. After the first request deducts inventory and completes, a retry can acquire the lock and deduct again.

Locks address inventory contention; idempotency keys address duplicate requests.

How much to put in one transaction#

If order rows and inventory live in the same database, start by wrapping both in one transaction.

1. Acquire exactly one processing order row via the idempotency key.
2. Run conditional inventory deduction.
3. On success, move the order to confirmed.
4. On insufficient stock, move the order to rejected.
5. Commit.

That reduces intermediate states like “order succeeded but inventory failed” or “inventory dropped with no order.”

When the payment processor is external, you cannot finish everything in one DB transaction. Whether to reserve at authorization, deduct after payment success, or when to release reservations is product policy—not a database feature. Without that policy, even the “right” locking model produces a shaky checkout experience.

A safe order API still leaves downstream steps#

After persisting the order, you may emit events to inventory, payment, shipping, and notifications. Duplication risk moves to those systems.

The DB commit can succeed and the process dies before publishing. Or an event goes out and the DB rolls back. Transactional outbox puts order changes and event records in the same DB transaction to reduce dual-write problems.[6]

Outbox does not make every downstream handler run exactly once. Messages can be delivered more than once.[3][6] Consumers for reservation, payment, and shipping must persist processed event IDs and reuse or skip on redelivery.[3]

Order API idempotency and consumer idempotency are contracts at different layers.

“Can’t we just use a Redis lock?”#

A distributed lock (e.g. Redis) can serialize work per product. As the sole source of truth, you inherit lock expiry, network partitions, lost ownership, and failures after taking the lock but before committing to the DB.

Distributed locks can reduce contention in a critical section. Confirming one order is still most directly enforced with DB uniqueness; inventory floors are still most directly enforced with conditional updates or DB locks. Locks should complement those rules, not replace them.

What must happen exactly once?#

Preventing duplicate orders is not picking one technique. You need answers to:

  1. Does the same order attempt carry a stable identifier across retries?
  2. Is that identifier protected by a DB uniqueness constraint?
  3. For the same key in processing, success, rejection, or unknown, do you return distinct, correct responses?
  4. When different orders compete, do conditional updates or appropriate locks prevent negative inventory?
  5. Are order confirmation and inventory deduction bundled atomically where they share a database?
  6. If payment, shipping, or notification events redeliver, do consumers avoid repeating side effects?
  7. Does every new order get a new key?

Code that stops duplicate orders is not code that disables a button. It is code that makes the system prove—separately—same order vs new order, stock remaining, and whether downstream work already ran.

References#