One click should result in one order. However, in web applications, this does not always happen. Users may click the pay button twice, the browser may repeat requests when the connection is unstable, or the payment service may send webhooks more than once.
If the endpoint simply executes INSERT every time it receives a request, the result could be two orders, two stock reductions, or two payment records. Disabling the button in JavaScript does help, but it is not the primary protection. Requests can still be resent from the browser, mobile applications, proxies, or external systems.
A stronger pattern is called idempotency: the same request can be processed multiple times, but the final effect occurs only once.
Idempotency with a Simple Analogy
Imagine you submit a payment form with a unique reference number. The officer may receive that form multiple times, but upon seeing the same reference number, they know that the form has already been processed.
In applications, that reference number is called an idempotency key. This key is created by the client or server before the important process begins and is sent along with the request. For example:
order-8f3a...for order creation.payment-2026-00091for payment.- Event ID from the payment provider for webhooks.
This key must be stored in the database with unique constraints. Do not just check using SELECT and then perform INSERT, as two requests arriving almost simultaneously could both pass the check.
Do Not Rely Solely on JavaScript
On the interface side, the button can be disabled after being clicked to prevent users from accidentally sending repeated requests. This is good for user experience, but not a security mechanism.
JavaScript can fail to load, the browser can retry, and requests can be sent directly without going through the page you created. Therefore, duplication prevention must be on the server and database side.
The database is in a better position to enforce the rule of “one key may only appear once.” In MySQL, create an idempotency key column with a UNIQUE index:
CREATE TABLE orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
idempotency_key VARCHAR(100) NOT NULL,
status ENUM('pending', 'paid', 'failed') NOT NULL DEFAULT 'pending',
total DECIMAL(12,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_orders_idempotency (idempotency_key)
) ENGINE=InnoDB;With this rule, two requests with the same key cannot create two order rows. One request will succeed, while the next request must be treated as a repetition of an existing operation.
Combine with Transactions
Transaction is a set of database changes treated as a single unit. If the entire process succeeds, the application executes COMMIT. If there is a failure, changes can be rolled back with ROLLBACK.
In PHP, PDO provides beginTransaction(), commit(), and rollBack(). Transactions are suitable when order creation involves multiple steps, such as saving the order, saving product details, and reducing stock.
<?php
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$key = $_POST['idempotency_key'] ?? '';
$userId = 17;
$total = 149000;
if ($key === '' || strlen($key) > 100) {
http_response_code(422);
exit('Invalid idempotency key');
}
try {
$pdo->beginTransaction();
$insert = $pdo->prepare(
'INSERT INTO orders (user_id, idempotency_key, total)
VALUES (:user_id, :key, :total)'
);
$insert->execute([
':user_id' => $userId,
':key' => $key,
':total' => $total
]);
$orderId = $pdo->lastInsertId();
// Save order details and reduce stock here.
$pdo->commit();
echo json_encode([
'order_id' => $orderId,
'status' => 'created'
]);
} catch (PDOException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
// SQLSTATE 23000 generally indicates a constraint violation.
if ($e->getCode() === '23000') {
$find = $pdo->prepare(
'SELECT id, status FROM orders WHERE idempotency_key = :key'
);
$find->execute([':key' => $key]);
$existing = $find->fetch(PDO::FETCH_ASSOC);
if ($existing) {
echo json_encode([
'order_id' => $existing['id'],
'status' => $existing['status'],
'duplicate' => true
]);
exit;
}
}
http_response_code(500);
echo json_encode(['error' => 'Order failed to process']);
}This example uses prepared statements to ensure that request values are not directly appended to SQL. The implementation details need to be adjusted according to the application structure, but the idea is important: the database decides whether the key has been used before.
Do Not Treat All Duplicate Requests the Same
When the second request arrives, the server needs to know whether that request is truly the same as the first request. Therefore, in addition to storing the key, you can store some additional information:
- Payload or hash of the request content.
- Process status, such as
pending,paid, orfailed. - Last response sent to the client.
- Expiration time of the key if the operation is only valid for a certain period.
If the same key is sent with a different total, do not silently use the new data. Reject the request as there may be a client error or key misuse.
Transactions Are Not a Substitute for Idempotency
Transactions maintain the consistency of multiple changes in one process. However, transactions alone do not automatically prevent the same request from creating two orders. Two requests could each execute transactions and both succeed.
Conversely, a unique constraint alone is also not sufficient if one process consists of many changes. An order may be successfully created, but stock may fail to be reduced. A safer combination is:
- Use an idempotency key to identify the same operation.
- Use a unique constraint to prevent duplication atomically.
- Use transactions to bind related changes.
- Handle duplicate errors as a result of repetition, not always as a failure.
Be Aware of Deadlocks and Long Processes
Transactions should be kept short. Do not send emails, call payment APIs, or wait for external processes while the database transaction is still open. The longer a transaction runs, the longer the lock can be held.
MySQL documents that deadlocks can cause entire transactions to be canceled, so the application needs to be ready to retry transactions under certain conditions. Therefore, save core changes as quickly as possible, then run subsequent work through a queue or asynchronous process if necessary.
What Does This Mean for Us?
Order duplication is a flow design issue, not just a problem of buttons being clicked too quickly. Start with operations that directly impact money, stock, quotas, or notification delivery.
Add an idempotency key to those endpoints, set a unique index in the database, and wrap related changes with transactions. After that, test often-forgotten scenarios: double clicks, refreshing during the process, retries due to timeouts, two parallel requests, and resent webhooks.
The documentation on PDO transactions explains the use of commit and rollback, while the documentation on InnoDB discusses duplicate keys, deadlocks, and lock timeouts. Both are worth reading before applying this pattern in a production system.
Sources & Further Reading
- PHP Manual: Transactions and auto-commit
- PHP Manual: PDO::beginTransaction
- MySQL 8.4 Reference Manual: InnoDB Error Handling
- MySQL 8.4 Reference Manual: Optimizing InnoDB Transaction Management
– Rio Yotto @rioyotto
