Imagine this scenario:
A customer clicks Place Order.
The request reaches your server, the database successfully creates the order, but the response never reaches the browser because of a network issue.
The customer assumes something went wrong and clicks again.
Now you have two orders instead of one.
This is a common reliability problem in web applications.
Disabling a submit button might prevent accidental double-clicks, but it doesn't guarantee that an operation will execute only once.
Let's explore how to handle this properly using React, Node.js, and PostgreSQL.
1. Frontend protection is only the first layer
In React, we often prevent repeated submissions using a loading state:
const [isSubmitting, setIsSubmitting] = useState(false); async function handleSubmit() { if (isSubmitting) return; setIsSubmitting(true); try { await createOrder(); } finally { setIsSubmitting(false); } } This is useful for improving the user experience.
But there is a problem.
Duplicate requests can still happen because of:
- Network retries
- Multiple browser tabs
- Concurrent API calls
- Repeated webhook deliveries
- Client or server failures
Even rapid calls within the same render cycle can observe the same React state before an update takes effect.
The important lesson: frontend protection improves UX, but backend protection ensures data integrity.
2. Understanding idempotency
An operation is idempotent when performing it multiple times has the same intended effect as performing it once.
For example:
POST /api/orders Idempotency-Key: order-abc-123 If the same request is submitted again with the same idempotency key, the server should recognize that the operation has already been processed.
Instead of creating a new order, it can return the result of the original operation.
This is particularly important for:
- Order creation
- Payment processing
- Subscription activation
- Inventory adjustments
- Financial transactions
3. Implementing idempotency in React
We can generate a unique idempotency key using the browser's built-in Web Crypto API.
import { useRef } from "react"; function OrderForm() { const idempotencyKeyRef = useRef(null); async function createOrder(order) { // Generate once for this intended operation. if (!idempotencyKeyRef.current) { idempotencyKeyRef.current = crypto.randomUUID(); } const response = await fetch("/api/orders", { method: "POST", headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKeyRef.current, }, body: JSON.stringify(order), }); if (!response.ok) { throw new Error("Order request failed"); } return response.json(); } // Use createOrder in your submission flow. } The key detail is that the same idempotency key must be reused when retrying the same intended operation.
Generate a new key only when the user starts a genuinely new order.
In a real application, you may need to persist the key beyond the component lifecycle if an unfinished operation must survive a page refresh.
4. Protecting the database against duplicate inserts
Frontend protection and idempotency middleware are useful, but concurrent requests can still introduce race conditions if the database isn't protected.
Consider this backend logic:
const existingOrder = await findOrder(key); if (!existingOrder) { await createOrder(data); } Looks fine, right?
Not necessarily.
Two requests can execute simultaneously:
Request A Request B | | Check existing order Check existing order | | Not found Not found | | Create order Create order | | Success Success Both requests can pass the check before either creates the record.
This is a classic check-then-insert race condition.
A better approach: Database uniqueness constraints
In PostgreSQL, enforce uniqueness at the database level.
CREATE UNIQUE INDEX unique_order_request ON orders (user_id, idempotency_key); This assumes your orders table contains the corresponding columns.
Now use PostgreSQL's ON CONFLICT functionality:
INSERT INTO orders ( user_id, idempotency_key, product_id, quantity, request_hash ) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, idempotency_key) DO NOTHING RETURNING id; If the insert succeeds, the query returns the new order ID.
If another request already used the same key, PostgreSQL prevents a duplicate insert.
The backend can then retrieve the existing order.
One additional safeguard is important: compare the stored request hash with the incoming request's hash.
If the same idempotency key is reused with different order parameters, return a conflict response instead of silently accepting the changed request.
Database constraints provide protection that application-level checks alone cannot guarantee.
5. What about transactions?
Preventing duplicate orders is only part of the problem.
Creating an order might involve multiple operations:
- Create the order.
- Reserve inventory.
- Update related database records.
- Record the transaction.
- Send a notification.
If one operation fails halfway through, the system can end up in an inconsistent state.
Database transactions help ensure that related database modifications succeed or fail together.
However, external operations such as sending emails or publishing messages are not automatically rolled back with a database transaction.
For these cases, patterns such as the transactional outbox and idempotent event consumers can improve reliability.
This is an important distinction when designing systems that interact with external services.
6. Test real failure scenarios
A feature working during normal testing doesn't necessarily mean it is reliable under failure conditions.
Here are five scenarios worth testing:
| Scenario | Expected behavior |
|---|---|
| Two concurrent requests with the same key | Only one order is created |
| Retry after the original request commits | Existing order is returned |
| Same key with different request data | Request is rejected |
| Failed database transaction | No partial order is committed |
| Two legitimate operations with different keys | Both can proceed |
These tests provide more confidence than simply checking whether a submit button becomes disabled.
7. The approach I recommend
For reliable applications, multiple layers should work together:
Frontend safeguards improve the user experience and discourage accidental repeated submissions.
Backend idempotency identifies repeated attempts to perform the same operation.
Database constraints protect against duplicate inserts, including concurrent requests.
Transactions and carefully designed side effects help maintain consistency across the entire workflow.
No single layer solves every problem.
The goal isn't just to make an API request succeed.
It's to make sure retries, failures, and concurrent requests don't produce unintended results.
Final thoughts
Building reliable software requires thinking beyond the happy path.
A submit button can be disabled. A network request can fail. A server can process the same operation more than once.
The application should still behave predictably.
A combination of frontend safeguards, idempotency, database constraints, and transaction handling is generally more reliable than depending on any one of them.
What's your approach to preventing duplicate requests in production applications?
Further reading: PostgreSQL INSERT / ON CONFLICT documentation
![AMR Software Architecture and Development — Ch.20: Cloud and Edge Integration [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqzr2jyxw3pvhnrsrs9iz.png)
![AMR Software Architecture and Development — Ch.21: Cybersecurity for AMR [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F85j3mb5otb8bgxh118z7.png)