To prevent a PAYMENT Button from being clicked multiple times
In a production payment application, we should prevent multiple clicks on the Pay Now button at both the frontend and backend levels.
The important thing to understand is:
Disabling the button prevents accidental clicks in the UI, but Idempotency Keys prevent duplicate payments at the backend.
Let’s understand this using an Angular + Backend + Payment Gateway architecture.
1. Frontend: Disable the button after the first click
When the user clicks Pay Now:
- Set
isPaymentProcessing = true. - Disable the Pay Now button immediately.
- Send the payment request to the backend.
- Keep the button disabled while payment is processing.
- Re-enable it only when the operation reaches an appropriate terminal state.
Angular example
isPaymentProcessing = false;
makePayment() {
if (this.isPaymentProcessing) {
return;
}
this.isPaymentProcessing = true;
this.paymentService.makePayment().subscribe({
next: (response) => {
console.log('Payment successful', response);
this.isPaymentProcessing = false;
},
error: (error) => {
console.error('Payment failed', error);
this.isPaymentProcessing = false;
}
});
}HTML:
<button
[disabled]="isPaymentProcessing"
(click)="makePayment()">
{{ isPaymentProcessing ? 'Processing...' : 'Pay Now' }}
</button>Example: Payment button states
Pay Now
Click the button to simulate starting a payment.
This is the first level of protection.
However, frontend protection alone is NOT sufficient.
For example:
- User refreshes the browser.
- Network retries the request.
- User opens multiple browser tabs.
- Someone sends the API request directly using Postman.
- The browser sends the same request twice.
Therefore, we need backend protection.
2. Backend: Implement Idempotency Keys
This is the most important production-level solution.
When a user initiates a payment, generate a unique idempotency key for that payment attempt.
Get Piyali Das 🧡🤍💚’s stories in your inbox
Join Medium for free to get updates from this writer.
Example:
POST /api/payments
Idempotency-Key: payment-12345
Content-Type: application/jsonRequest body:
{
"orderId": "ORD-1001",
"amount": 5000,
"currency": "INR"
}The backend stores the idempotency key and payment result.
How does it work?
| Request | Idempotency Key | Backend action |
| ------------ | --------------- | ----------------------------- |
| First click | `PAY-123` | Create payment |
| Second click | `PAY-123` | Return existing result/status |
| Third click | `PAY-123` | Return existing result/status |
| Fourth click | `PAY-123` | Return existing result/status |Key takeaway: The same idempotency key (PAY-123) ensures that multiple clicks on the payment button result in only one payment being created. Subsequent requests return the existing payment result instead of creating duplicate payments.
Only one payment should be created for that logical payment attempt.
3. Database: Maintain payment status
A production payment system can maintain a payment record like this:
| Payment ID | Order ID | Amount | Status |
| ---------- | -------- | ------ | ------- |
| PAY-101 | ORD-1001 | ₹5,000 | SUCCESS |
| PAY-102 | ORD-1002 | ₹2,000 | PENDING |
| PAY-103 | ORD-1003 | ₹1,000 | FAILED |Also maintain a unique constraint on the idempotency key, scoped appropriately to the customer or merchant.
For example:
CREATE TABLE payments (
payment_id VARCHAR(100) PRIMARY KEY,
order_id VARCHAR(100) NOT NULL,
idempotency_key VARCHAR(255) NOT NULL,
amount DECIMAL(12,2) NOT NULL,
status VARCHAR(20) NOT NULL,
UNIQUE (idempotency_key)
);In production, the backend must also handle concurrent requests atomically. Simply checking the database and then inserting a record is not enough because two requests might pass the check simultaneously.
4. What if the payment is still processing?
This is an important production scenario.
Suppose:
- User clicks Pay Now.
- Backend sends the payment request to the gateway.
- Payment gateway is processing the transaction.
- User clicks Pay Now again.
The backend should not initiate another payment.
Instead, it should return the existing payment’s status:
{
"paymentId": "PAY-101",
"status": "PENDING",
"message": "Payment is being processed"
}The frontend can display:
Your payment is being processed. Please do not retry.
If the network times out, the frontend should check the existing payment status using the same payment attempt rather than blindly creating another payment.
5. Complete production-level approach
| Layer | Protection | Purpose |
| ---------------- | ------------------------------- | ----------------------------------------------------- |
| Angular Frontend | Disable button | Prevent accidental multiple clicks |
| Frontend | Generate/reuse Idempotency Key | Identify the same payment attempt |
| Backend API | Idempotency validation | Prevent duplicate payment processing |
| Database | Unique constraint + transaction | Handle concurrent requests safely |
| Payment Gateway | Gateway-supported idempotency | Prevent duplicate charges at gateway |
| Webhook Handler | Event deduplication | Prevent duplicate processing of payment notifications |Key takeaway: Duplicate payment prevention requires protection at multiple layers, not just disabling the frontend button.
Each layer provides a different level of protection, ensuring that even retries, concurrent requests, gateway timeouts, and duplicate webhooks do not result in duplicate payments.
My recommendation: For a banking or payment application, implement all these protections rather than relying only on disabling the button.
The most important distinction for your system design interview is:
- Frontend button disabling = User experience protection.
- Backend idempotency = Business-level duplicate payment protection.
- Database constraints and gateway idempotency = Production-level reliability.
This is how real-world payment systems protect users from being charged multiple times for the same payment attempt.









