Imagine a user presses the "Pay" button, and then the internet connection drops before the page receives a response from the server. Was the payment successful or not? Not knowing, the application may attempt to send the same request again. If the server processes both requests immediately, the result could be two orders, two stock reductions, or two transactions.
Such issues do not only occur in large payment systems. Order forms, ticket creation, email sending, member registration, and other processes that modify data can also experience this. The solution is not just to prohibit retries, but to make the endpoint capable of recognizing the same request. This pattern is known as idempotency.
What does idempotency mean?
In simple terms, an idempotent operation is one that produces the same primary effect even if identical requests are sent multiple times. HTTP defines several methods such as GET, PUT, and DELETE as idempotent based on the effects requested from the server. However, POST is typically not automatically idempotent because each call can create new resources.
For example, calling PUT /users/15 with the same name data multiple times should still result in one final condition. Conversely, calling POST /orders three times could create three orders if the server does not have a mechanism to prevent duplication.
It is important to note that idempotent does not mean the server does not perform any side effects. The server can still log or track statistics for each request. What matters is that the primary business effect does not multiply.
Why can't retries be avoided?
In web systems, failure does not always mean the operation on the server has failed. The server may have already saved the order, but the response is lost when the network drops. From the client's perspective, the situation appears as "not successful," even though the server has already processed it.
Retries can also occur because:
- the user presses the button twice because the page seems unresponsive;
- the browser or application repeats the request after a connection issue;
- intermediary services like queues or workers rerun jobs considered failed;
- timeout occurs before the client receives a response;
- the user refreshes the confirmation page and resubmits the data.
Therefore, endpoints that create important data must be designed with the assumption that requests can be received more than once.
Use idempotency keys for important operations
Idempotency key is a unique identifier sent by the client to mark a single business intent. For example, when a user creates an order, the application generates a key like order-8f2c.... If the request is resent with the same key, the server does not create a new order. The server simply returns the result from the first processing.
Example request:
POST /api/orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: 8f2c6f36-2d4a-4c7a-9b67-1b1d8c9e1234
{
"product_id": 42,
"quantity": 1
}The key should be generated by the client for a single user action, not regenerated each time the retry function is executed. This way, the first attempt and the second attempt are still recognized as the same operation.
Store the result, not just the key
A common mistake is storing the idempotency key as an indicator that the request has been received, but not storing the result of the response. As a result, the second request does not create new data, but the client does not receive information about the order that has already been created.
In the database, we can create a simple table:
CREATE TABLE idempotency_keys (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
request_key VARCHAR(100) NOT NULL,
request_hash CHAR(64) NOT NULL,
response_code SMALLINT NOT NULL,
response_body JSON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_user_request (user_id, request_key)
);request_hash is useful to ensure that a key is not reused for different request contents. If the same key is suddenly used for a different product or quantity, the server should reject that request. This prevents client bugs and key misuse.
Safe processing order in PHP and MySQL
The practical implementation needs to involve database transactions. The simple flow is as follows:
- Retrieve the
Idempotency-Keyfrom the header and validate its format. - Calculate the hash of the normalized payload.
- Look for the key based on the user or account context.
- If it exists, compare the payload hash. If they match, return the stored response. If they differ, return an error.
- If it does not exist, start a transaction.
- Create the order, reduce stock, or perform other business changes.
- Store the response result in the idempotency table.
- Commit the transaction, then send the response to the client.
Example PHP pseudocode:
$key = $_SERVER['HTTP_IDEMPOTENCY_KEY'] ?? '';
$payload = getNormalizedJsonBody();
$hash = hash('sha256', json_encode($payload));
$db->beginTransaction();
$record = findIdempotencyKey($userId, $key);
if ($record) {
if (!hash_equals($record['request_hash'], $hash)) {
throw new ApiException(409, 'Key has already been used for a different payload');
}
$db->commit();
return jsonResponse($record['response_body'], $record['response_code']);
}
$orderId = createOrderAndUpdateStock($payload);
$response = ['order_id' => $orderId, 'status' => 'created'];
saveIdempotencyResult($userId, $key, $hash, 201, $response);
$db->commit();
return jsonResponse($response, 201);In real implementations, error handling is also important. If the business process fails and the transaction is rolled back, the failure result does not necessarily need to be stored. However, this rule must be consistent. Some services choose to store certain error responses so that the next retry receives the same result, while validation errors are usually not stored because the request has not been processed.
Don't forget about concurrent request issues
Two requests with the same key can arrive almost simultaneously. If both check the table first and find that the key does not exist, both can proceed with the process.
This is where unique indexes and transactions become important. The database should be the last line of defense, not just PHP logic. Use unique constraints, handle duplicate key errors, and ensure that order creation and key logging operations are within the appropriate transaction.
Additionally, avoid storing keys indefinitely. Idempotency data can be cleaned up after a certain period, such as a few hours or a few days, depending on business risk. This period should be long enough to accommodate reasonable retries but not so long that the table grows uncontrollably.
What you can do now
- Identify endpoints that create orders, payments, tickets, accounts, or important changes.
- Determine whether those endpoints are safe to repeat or require an idempotency key.
- Add unique constraints in the database, not just checks in the application code.
- Store payload hashes and responses so that retries can receive consistent results.
- Test timeout scenarios, double-clicked buttons, concurrent requests, and connection drops after commit.
- Carefully log keys, but do not include sensitive data in the keys.
Idempotency is not a substitute for transactions, validation, or stock control. It is an additional layer to address the reality that networks and clients cannot always inform whether an operation has succeeded. With this design, retries shift from a source of duplication to a safer recovery mechanism.
Sources & further reading
- RFC 9110: HTTP Semantics
- Idempotent - MDN Web Docs Glossary
- Idempotency-Key header - MDN Web Docs
- Idempotent requests - Stripe API Reference
– Rio Yotto @rioyotto
