How to Prevent Duplicate API Requests After a Timeout
A user clicks “Pay,” “Create Order,” “Submit Ticket,” or “Sign Up.” The request times out. The frontend shows an error. The user clicks again. Later, you find two orders, two payments, two support tickets, two accounts, or two webhook events. This guide explains why timeouts are dangerous and how to prevent duplicate API actions with idempotency keys, safer retry rules, client request IDs, backend constraints, and better debugging notes.
Quick answer: A timeout does not prove the server failed. It only proves the client stopped waiting. The original request may still complete on the backend. To prevent duplicates, unsafe create/update actions need server-side duplicate protection, usually with an idempotency key, unique request ID, transaction record, database constraint, or event log. Frontend button disabling helps user experience, but it is not enough by itself.
Why API timeouts create duplicate actions
A timeout is one of the most misunderstood API errors. When a request times out, beginners often assume the server did nothing. That assumption is dangerous. A timeout usually means the client, browser, gateway, proxy, load balancer, mobile app, or frontend code stopped waiting for a response. It does not automatically mean the backend stopped processing the request.
Here is the common duplicate-request timeline:
The user sends a create request
The frontend sends a POST /orders, POST /payments, POST /tickets, or POST /signup request.
The backend receives it and starts working
The server may create a database row, call a payment provider, send an email, generate a file, or trigger another internal job.
The client times out before the response arrives
The browser, mobile app, proxy, or API client stops waiting. The user sees “Request failed,” “Network error,” or “Something went wrong.”
The user or client retries
The same action is submitted again, often with a new request body and no duplicate protection.
The backend processes the action twice
Now you may have two orders, two charges, two accounts, two tickets, duplicate emails, duplicate rows, or duplicate webhook side effects.
Important: “The frontend did not get a response” and “the backend did not complete the action” are not the same thing. Duplicate prevention exists because distributed systems can fail in the middle, after the server started work but before the client received the result.
Safe retries vs unsafe retries
Not every retry has the same risk. A retry for a read-only request is very different from a retry that creates money movement, orders, accounts, tickets, files, or irreversible side effects.
| Method or action | Duplicate risk | Retry guidance |
|---|---|---|
GET /products |
Low if it only reads data. | Usually safe to retry with backoff if the API allows it. |
HEAD /resource |
Low if it only checks metadata. | Usually safe to retry. |
PUT /profile/123 |
Usually lower than POST if it replaces a known resource. | Often retryable, but still check side effects such as emails or downstream jobs. |
DELETE /file/123 |
Usually idempotent from an HTTP meaning, but may still trigger side effects. | Retry carefully if the API handles already-deleted resources predictably. |
POST /orders |
High. The same payload may create multiple orders. | Do not blindly retry unless you use an idempotency key or server duplicate guard. |
POST /payments |
Very high. Duplicate charges are serious. | Use provider idempotency keys, transaction references, and server-side duplicate checks. |
POST /tickets |
Medium. Duplicate support tickets confuse users and teams. | Use client request IDs or dedupe by user, subject, and time window. |
| Webhook processing | High. Providers often retry delivery. | Store event IDs and process each event once. |
The safest beginner rule is simple: retry read requests more freely, but protect write requests before retrying them. Any request that creates, charges, sends, imports, books, subscribes, or modifies data should have duplicate protection on the server side.
Idempotency keys explained for beginners
An idempotency key is a unique value sent with a request so the backend can recognize repeated attempts of the same intended action. The client can retry with the same key after a timeout. The server checks whether it already processed that key. If yes, it returns the stored result or a safe duplicate response instead of doing the side effect again.
Example request header:
Idempotency-Key: checkout_2026_07_02_user_123_attempt_abc789
The key should represent one intended operation, not one network attempt. That means:
- One checkout attempt should reuse the same idempotency key across retries.
- A new checkout attempt should get a new key.
- A payment retry after a network timeout should use the same key.
- A totally different payment should use a different key.
- The backend, not just the frontend, must enforce the key.
Practical rule: The frontend can generate or send an idempotency key, but the backend must store and enforce it. A header alone does nothing if the server ignores it.
What the backend should store
A strong idempotency implementation stores enough information to decide whether a retry is the same operation and what response to return.
| Stored field | Why it matters |
|---|---|
| Idempotency key | Identifies the operation across retries. |
| User or account ID | Prevents one user from reusing another user’s key. |
| Endpoint or operation name | Prevents the same key from being reused for unrelated actions. |
| Request body hash | Detects when the same key is reused with different payload data. |
| Status | Tracks pending, completed, failed, or expired operations. |
| Response body or resource ID | Lets the retry return the original successful result. |
| Created and expiry time | Allows safe cleanup of old keys. |
The safest debugging order after a timeout
When duplicate records already happened, do not only ask “Why did the request timeout?” Also ask “Why did the retry create a second side effect?” The timeout is the trigger. Missing duplicate protection is the design problem.
Find where the timeout came from
Was it the browser, Axios, fetch cancellation, mobile app, API gateway, reverse proxy, hosting platform, load balancer, or backend worker? A frontend timeout and a gateway 504 tell different stories.
Check whether the original request completed later
Search server logs, database rows, payment provider logs, email logs, job queues, and webhook logs. Do not assume a timeout means no side effect happened.
Compare the first request and retry
Check method, URL, user ID, request body, headers, token, request ID, idempotency key, timestamp, and backend trace ID. Look for whether the retry used the same operation key or looked like a brand-new action.
Classify the action
Was it a read, update, create, payment, signup, file import, email send, booking, or webhook event? The risk level determines how strict your duplicate protection must be.
Check for server-side duplicate guards
Look for an idempotency key table, unique database constraint, order reference, provider transaction ID, event ID log, request hash, or status record. If none exists, the backend is probably relying too much on the client.
Fix retry rules
Do not automatically retry unsafe POST actions unless the backend can safely dedupe them. Use backoff, respect Retry-After, and stop retrying when the result is unknown but dangerous.
Return clearer responses
When a duplicate is detected, return a clear result such as “already processed,” the original resource ID, or a 409 Conflict response with a useful JSON body. Do not leave clients guessing.
Frontend duplicate prevention helps, but it is not enough
Frontend protections are still useful. They reduce accidental double-clicks, impatient retries, repeated form submissions, and duplicate loading states. But frontend protection is only the first layer. Users can refresh, open two tabs, lose connection, retry from a mobile app, or trigger the same action from another client.
Good frontend protections
- Disable the submit button while the request is in progress.
- Show a clear loading state such as “Creating order…” or “Processing payment…”
- Prevent double-click form submission.
- Use a stable client request ID for one form submission attempt.
- Use
AbortControllerfor cancellation, but remember cancellation does not always stop server work already started. - Avoid automatic retries for dangerous create/payment actions unless idempotency is implemented.
- After a timeout, show “We are checking whether this completed” instead of immediately encouraging another submit.
Example button guard in browser JavaScript:
let isSubmitting = false;
async function submitOrder(orderData) {
if (isSubmitting) {
return;
}
isSubmitting = true;
try {
const response = await fetch("https://api.example.com/v1/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"Idempotency-Key": orderData.idempotencyKey
},
body: JSON.stringify(orderData)
});
const data = await response.json();
if (!response.ok) {
console.error("Order failed:", response.status, data);
return;
}
console.log("Order created:", data);
} finally {
isSubmitting = false;
}
}
Frontend warning: Disabling a button prevents some duplicate clicks, but it cannot protect your system from network retries, browser refreshes, multiple tabs, mobile retries, webhook redelivery, malicious clients, or backend job duplication. The backend still needs duplicate protection.
Backend duplicate prevention patterns
The backend is where duplicate prevention becomes reliable. The exact implementation depends on the action, but the principle is the same: store a durable record that lets the server recognize repeated attempts and avoid repeating side effects.
Pattern 1: Idempotency key table
This is useful for payments, checkouts, subscriptions, order creation, ticket creation, and any create action where the client may retry after an unknown result.
CREATE TABLE api_idempotency_keys (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
idempotency_key VARCHAR(120) NOT NULL,
operation VARCHAR(80) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(30) NOT NULL,
response_status INT NULL,
response_body JSON NULL,
resource_id VARCHAR(120) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_user_operation_key (user_id, operation, idempotency_key)
);
Basic backend flow:
- Receive request with
Idempotency-Key. - Calculate a hash of the meaningful request body.
- Try to create a unique idempotency record.
- If the key already exists with the same hash and completed status, return the stored response.
- If the key exists with a different hash, reject it as a key misuse problem.
- If the key is new, process the operation and store the final result.
Pattern 2: Unique business reference
Sometimes the best duplicate guard is a natural business key. For example:
- One order per checkout session ID.
- One payment per payment intent ID.
- One account per normalized email address.
- One import result per file hash and user ID.
- One booking per reservation hold ID.
Example database constraint:
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
checkout_session_id VARCHAR(120) NOT NULL,
total_cents INT NOT NULL,
status VARCHAR(30) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_checkout_order (user_id, checkout_session_id)
);
Pattern 3: Webhook event log
Webhook providers often retry delivery when your endpoint times out or returns an error. That means your webhook handler should treat duplicate event delivery as normal, not surprising.
CREATE TABLE processed_webhook_events (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
provider VARCHAR(50) NOT NULL,
event_id VARCHAR(150) NOT NULL,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_provider_event (provider, event_id)
);
Before processing a webhook side effect, store or check the provider event ID. If the event was already processed, return a success response without repeating the action.
Pattern 4: Pending operation state
For long-running operations, use a pending state instead of making the client wait forever. The first request creates a job or operation record. The client can poll the operation status later.
{
"operationId": "op_789",
"status": "processing",
"message": "Your import has started. Check this operation ID for progress."
}
This pattern is safer for file imports, report generation, AI jobs, large exports, background sync, and slow third-party integrations.
Practical request and response examples
cURL request with an idempotency key
curl -i -X POST "https://api.example.com/v1/orders" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "Idempotency-Key: checkout_abc123" \
--data '{"checkoutSessionId":"cs_123","totalCents":4999}'
fetch request with timeout and idempotency key
This example uses AbortController style timeout logic. The important part is that the idempotency key represents the checkout attempt, not the individual network attempt.
async function postOrderWithTimeout(orderData) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch("https://api.example.com/v1/orders", {
method: "POST",
signal: controller.signal,
headers: {
"Authorization": "Bearer YOUR_TOKEN",
"Content-Type": "application/json",
"Accept": "application/json",
"Idempotency-Key": orderData.idempotencyKey
},
body: JSON.stringify({
checkoutSessionId: orderData.checkoutSessionId,
totalCents: orderData.totalCents
})
});
const data = await response.json();
return {
ok: response.ok,
status: response.status,
data: data
};
} catch (error) {
if (error.name === "AbortError") {
return {
ok: false,
status: "timeout",
message: "The request timed out. The server may still complete it."
};
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
Successful first response
{
"status": "created",
"orderId": "ord_123",
"idempotencyKey": "checkout_abc123"
}
Safe retry response after the first request already succeeded
{
"status": "already_processed",
"orderId": "ord_123",
"idempotencyKey": "checkout_abc123",
"message": "This checkout attempt was already completed. Returning the original order."
}
Key reused with a different payload
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": "idempotency_key_conflict",
"message": "This idempotency key was already used with a different request body.",
"idempotencyKey": "checkout_abc123"
}
A duplicate-safe API should be predictable. If the same key and same payload are retried, return the original result. If the same key is reused for a different payload, reject it clearly.
Retry rules that reduce duplicate damage
Retry logic should be boring, slow, and careful. Aggressive retries can turn one slow backend into a bigger outage, and blind retries can create duplicates.
| Condition | Retry? | Safer behavior |
|---|---|---|
Network error on GET |
Usually yes | Retry with backoff and a max attempt limit. |
Timeout on GET |
Usually yes | Retry with backoff if the endpoint is read-only. |
Timeout on POST /orders |
Only with duplicate protection | Retry with the same idempotency key or check order status first. |
Timeout on POST /payments |
Only with strong protection | Use payment provider idempotency, transaction reference, and status lookup. |
429 Too Many Requests |
Maybe later | Respect Retry-After when provided and reduce request rate. |
503 Service Unavailable |
Maybe later | Respect Retry-After when provided and use backoff. |
400, 401, 403, 404 |
Usually no automatic retry | Fix request, auth, permission, or URL instead of repeating the same failure. |
409 Conflict |
Not blindly | Read the error body. It may mean duplicate, version conflict, or already processed. |
Simple retry with backoff idea
This example is intentionally conservative. It retries only when the action is marked safe or when you provide an idempotency key for an unsafe action.
async function retryApiRequest(makeRequest, options) {
const maxAttempts = options.maxAttempts || 3;
const baseDelayMs = options.baseDelayMs || 500;
const isSafeToRetry = options.isSafeToRetry === true;
if (!isSafeToRetry) {
throw new Error("Unsafe retry blocked. Add idempotency protection first.");
}
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await makeRequest();
if (result.ok) {
return result;
}
if (![429, 500, 502, 503, 504].includes(result.status)) {
return result;
}
lastError = result;
} catch (error) {
lastError = error;
}
const delay = baseDelayMs * Math.pow(2, attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
}
throw lastError;
}
Do not copy retry code blindly. The correct retry rule depends on the action. Retrying a product search is not the same as retrying a payment, checkout, subscription, booking, or webhook side effect.
Design better duplicate and timeout responses
Good API responses make duplicate prevention easier. A vague 500 or “Something went wrong” forces the frontend to guess. A clear response tells the client whether the operation is pending, completed, duplicated, conflicted, or safe to retry.
202 Accepted for slow work
Use this when the operation has started but will finish later. Return an operation ID so the client can check status.
200 OK for duplicate same result
If the same idempotency key already completed, returning the original successful result can be more useful than an error.
201 Created for first creation
Use this when the first request creates the resource and returns the new resource ID.
409 Conflict for key misuse
Use this when the same idempotency key is reused with a different request body or incompatible operation.
429 Too Many Requests for rate limits
Use this when the client is sending too many requests. Include helpful retry timing when possible.
503 Service Unavailable for temporary outage
Use this when the service is temporarily unable to handle the request, not when the client sent a bad request.
Clear timeout/pending response
{
"status": "processing",
"operationId": "op_123",
"message": "The request is still being processed. Check the operation status before retrying."
}
Clear duplicate response
{
"status": "already_processed",
"resourceId": "ord_123",
"message": "This request was already completed. Returning the existing result."
}
Real scenarios where duplicate protection matters most
| Scenario | What can go wrong | Best protection |
|---|---|---|
| Checkout order | User gets two orders after refreshing or retrying. | Checkout session ID, idempotency key, unique order constraint. |
| Payment or charge | Customer gets charged twice. | Payment provider idempotency key, payment intent ID, transaction reference. |
| Signup form | Duplicate accounts or duplicate welcome emails. | Unique normalized email, signup request key, email-send log. |
| Support ticket | Same issue appears as multiple tickets. | Client request ID, user/time dedupe, attachment hash. |
| Webhook handler | Same event triggers duplicate database updates or emails. | Provider event ID log and process-once handling. |
| File import | Same file imported twice and rows duplicated. | File hash, import job ID, processed-file log. |
| Email or SMS send | Customer receives duplicate messages. | Message intent ID, send log, unique notification key. |
| Booking or reservation | Same seat, room, or slot is booked twice. | Reservation hold ID, transaction boundary, unique booking constraint. |
Bad fixes that do not truly prevent duplicates
Only increasing the timeout
A longer timeout may hide the symptom, but it does not solve duplicate retries if the client or user eventually gives up and submits again.
Only disabling the button
This reduces double-clicks, but it does not stop retries from refreshes, multiple tabs, mobile reconnects, background jobs, or malicious clients.
Generating a new key on every retry
A new idempotency key makes each retry look like a new operation. Reuse the same key for the same intended action.
Using timestamps as the only duplicate guard
Time-window dedupe can miss real duplicates or block legitimate actions. Use stable operation identifiers when possible.
Trusting frontend validation only
The backend must enforce duplicate rules. Frontend code can be bypassed, repeated, or interrupted.
Returning generic 500 errors
A duplicate conflict or already-processed request should be explained clearly, not hidden as a generic server crash.
Copyable duplicate-timeout debugging checklist
Use this note when debugging a duplicate order, payment, ticket, signup, file import, or webhook event after a timeout.
Duplicate API request after timeout debugging note
User action:
Example: create order / payment / signup / support ticket / webhook / file import
Endpoint:
Example: POST /v1/orders
Client:
Browser fetch / Axios / mobile app / server job / webhook provider / WordPress / PHP
Timeout source:
Browser timeout / AbortController / Axios timeout / gateway 504 / proxy timeout / backend timeout / unknown
First request:
Timestamp:
Request ID:
Idempotency key:
User ID:
Request body hash:
Server log found? Yes / No
Side effect completed? Yes / No
Created resource ID:
Retry request:
Timestamp:
Request ID:
Idempotency key:
User ID:
Request body hash:
Server log found? Yes / No
Created resource ID:
Duplicate protection present:
Idempotency key table? Yes / No
Unique database constraint? Yes / No
Provider transaction ID? Yes / No
Webhook event log? Yes / No
File hash or import job ID? Yes / No
Client request ID? Yes / No
Retry behavior:
Automatic retry? Yes / No
User clicked again? Yes / No
Retry used same key? Yes / No
Retry generated new key? Yes / No
Likely root cause:
No server-side duplicate guard / retry used new key / timeout misunderstood / webhook event not logged / missing unique constraint / frontend double submit / gateway timeout but backend continued
Fix plan:
Add server-side duplicate protection.
Reuse the same idempotency key for the same operation.
Return original result for safe duplicate retries.
Return 409 if the same key is reused with a different body.
Improve frontend timeout message.
Add logs for request ID and operation ID.
Related CodeZips tools for this problem
Use these CodeZips tools when you want to turn a timeout, duplicate request, retry problem, or confusing API response into a cleaner debugging workflow.
API Idempotency Key and Duplicate Request Debugger
Use this first when you are dealing with duplicate orders, double payments, repeated signups, duplicate tickets, repeated webhook events, or unsafe retries.
API Timeout and Retry Debugging Helper
Use this when a request keeps loading, times out, returns 504, aborts in fetch, or creates confusion about whether the server completed the work.
API Rate Limit Header Explainer and Retry Plan Generator
Use this when retries are mixed with 429 Too Many Requests, quota limits, reset timing, or Retry-After headers.
API Request Troubleshooting Checklist Generator
Use this when the timeout may be mixed with method, URL, authentication, headers, body format, CORS, environment, or server log issues.
Webhook Payload Tester and Debug Note Generator
Use this when duplicate processing happens because a webhook provider retries the same event after your endpoint times out or returns an error.
JSON Error Response Builder for API Projects
Use this when you are building your own API and need clearer duplicate, conflict, timeout, pending, or already-processed JSON responses.
HTTP Status Code Debugging Helper
Use this when duplicate protection shows up as a status code such as 409 Conflict, 429 Too Many Requests, 503 Service Unavailable, or 504 Gateway Timeout.
API Error Response Explainer and Debug Checklist
Use this when an API returns a confusing JSON error body after a retry, duplicate request, timeout, conflict, or backend failure.
Technical references used for this guide
This guide is written as a practical debugging workflow, with retry and idempotency behavior checked against HTTP and API documentation.
FAQ
Does a timeout mean the API request failed?
No. A timeout usually means the client stopped waiting for the response. The server may still complete the work after the client gives up. That is why create, payment, signup, and webhook actions need duplicate protection.
What is an idempotency key?
An idempotency key is a unique value sent with a request so the server can recognize retries of the same intended operation. If the same request is retried with the same key, the server can return the original result instead of repeating the side effect.
Should I generate a new idempotency key on every retry?
No. For the same intended operation, retries should reuse the same idempotency key. A new key can make the retry look like a new operation and may create duplicates.
Is disabling the submit button enough to prevent duplicates?
No. Disabling the button helps prevent double-clicks, but it does not protect against network retries, page refreshes, multiple tabs, mobile reconnects, webhook redelivery, background jobs, or clients that bypass the UI.
Should I retry POST requests automatically?
Only if the POST operation is protected by idempotency or another server-side duplicate guard. Blindly retrying unsafe create actions can create duplicate orders, charges, tickets, accounts, or emails.
How should an API respond to a duplicate idempotency key?
If the same key and same payload already succeeded, the API can return the original result or an already-processed response. If the same key is reused with a different payload, a clear conflict response is usually safer.
How do webhooks cause duplicate processing?
Webhook providers often retry delivery when your endpoint times out or returns an error. Your webhook handler should store provider event IDs and process each event once, even if the same event is delivered multiple times.
What should I log to debug duplicate API requests?
Log request ID, idempotency key, user ID, operation name, request body hash, resource ID, status, timestamp, and upstream provider reference. These fields make it easier to prove whether two requests were retries of the same operation or separate actions.

