DEV Community

Roomnexa
Roomnexa

Posted on

How to Prevent Duplicate Bookings in a SaaS Application with Idempotency

Imagine a guest is booking a hotel room.
They click "Confirm Booking".
The request takes a few seconds because of network latency. The user thinks nothing happened and clicks the button again.
Now the backend receives two requests.
If the API processes both requests independently, the application might create:

  • Two reservations
  • Two payment attempts
  • Two booking numbers
  • Duplicate emails
  • Incorrect room availability This is a common problem in transactional SaaS applications. The solution is idempotency. In this article, we'll look at what idempotency means, why it matters for booking APIs, and how it can be implemented in a typical Node.js backend.

What Is Idempotency?
An operation is idempotent when performing it multiple times produces the same final result as performing it once.
For example:
Request 1 → Create booking → BK-1001
Request 2 → Same request → BK-1001
Request 3 → Same request → BK-1001
Instead of creating three bookings, the server recognizes that these requests represent the same operation.
This is especially important for APIs involving:

  • Payments
  • Orders
  • Reservations
  • Ticket purchases
  • Account creation
  • Subscription activation
  • Inventory operations

Why Duplicate Requests Happen
Duplicate requests aren't always caused by users double-clicking.
They can happen because of:

  1. Double-clicking A user clicks the submit button multiple times.
  2. Network retry The client sends a request but doesn't receive the response because of a temporary network problem. The client retries.
  3. Mobile connectivity A mobile application may lose connectivity immediately after sending a request. The user tries again.
  4. Browser refresh The frontend may repeat a request after a page reload or navigation.
  5. Automatic retry mechanisms Some HTTP clients, gateways or infrastructure components may retry failed requests. So simply disabling the button on the frontend is not enough. The backend should also protect the operation.

The Basic Idea
The client generates a unique idempotency key for an operation.
For example:
Idempotency-Key:
8f8d9c7e-5d4b-4b92-a2c1-123456789abc
The client sends it with the request:
POST /api/bookings
Idempotency-Key: 8f8d9c7e-5d4b-4b92-a2c1-123456789abc
The backend checks whether this key has already been processed.
If it hasn't:
Create booking
Store result
Return response
If it has:
Return previously stored result

A Simple Database Design
One approach is to create an idempotency table.
For example:
CREATE TABLE idempotency_keys (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
idempotency_key VARCHAR(255) NOT NULL UNIQUE,
request_hash VARCHAR(64),
response_status INT,
response_body JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
The important part is:
UNIQUE(idempotency_key)
This prevents multiple records from being created for the same key.

Node.js Example
Let's imagine we're using Express.
A simplified booking endpoint could look like this:
app.post("/api/bookings", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"];

if (!idempotencyKey) {
    return res.status(400).json({
        message: "Idempotency-Key is required"
    });
}

const existingRequest = await db.idempotencyKeys.findUnique({
    where: {
        idempotencyKey
    }
});

if (existingRequest) {
    return res
        .status(existingRequest.responseStatus)
        .json(existingRequest.responseBody);
}

// Continue with booking creation...
Enter fullscreen mode Exit fullscreen mode

});
After successfully creating the booking, we store the response:
const booking = await createBooking(req.body);

const responseBody = {
success: true,
bookingId: booking.id,
bookingNumber: booking.bookingNumber
};

await db.idempotencyKeys.create({
data: {
idempotencyKey,
responseStatus: 201,
responseBody
}
});

return res.status(201).json(responseBody);
This is only a simplified example. In production, the database transaction needs to be designed carefully.

The Important Part: Database Transactions
There is a subtle problem with the simple implementation.
Imagine this sequence:
Request A
↓
Check idempotency key
↓
Key doesn't exist
↓
Create booking
↓
Server crashes
↓
Response never returned
The user retries.
Now the second request may not know what happened.
This is why idempotency and transactional database operations often need to work together.
A safer flow is:
BEGIN TRANSACTION

Check idempotency key

If exists:
    Return stored response

Create booking

Store idempotency result
Enter fullscreen mode Exit fullscreen mode

COMMIT
The exact implementation depends on the database and architecture, but the important principle is to make the state changes atomic wherever possible.

Don't Trust the Idempotency Key Alone
There is another important consideration.
Suppose a client sends:
Idempotency-Key: ABC123
with this request:
{
"roomId": 101,
"checkIn": "2026-10-10",
"checkOut": "2026-10-12"
}
Later, the same key is accidentally reused with:
{
"roomId": 205,
"checkIn": "2026-10-15",
"checkOut": "2026-10-18"
}
Should the server accept it?
No.
The idempotency key should represent one specific operation.
That's why storing a hash of the original request can be useful.
For example:
Idempotency Key
+
Request Hash
↓
Stored Operation
If the same key arrives with a different request payload, the API can reject it.

Request Hashing
A simplified example using Node.js:
import crypto from "crypto";

function createRequestHash(data) {
return crypto
.createHash("sha256")
.update(JSON.stringify(data))
.digest("hex");
}
Then:
const requestHash = createRequestHash(req.body);
The backend can compare the new hash with the hash stored for the idempotency key.
If they don't match:
409 Conflict
or another appropriate client error can be returned.

Frontend Protection Still Matters
Backend idempotency should not replace frontend UX.
You should still prevent accidental multiple submissions.
For example:
const [loading, setLoading] = useState(false);

const handleBooking = async () => {
if (loading) return;

setLoading(true);

try {
    await createBooking();
} finally {
    setLoading(false);
}
Enter fullscreen mode Exit fullscreen mode

};
The frontend improves the user experience.
The backend provides the actual data integrity protection.
You need both.

Where Idempotency Becomes Really Important
A booking API is only one example.
Consider a payment flow:
Create Booking
↓
Initialize Payment
↓
Payment Gateway
↓
Payment Success
↓
Activate Booking
↓
Send Confirmation
If the payment callback is delivered twice, the backend should not:
Update payment twice
Generate two invoices
Activate booking twice
Send duplicate confirmations
Payment callbacks should therefore also be designed with idempotency in mind.

Idempotency vs Database Constraints
These solve related but different problems.
A database constraint might prevent:
duplicate booking number
But it doesn't necessarily prevent:
duplicate payment attempt
duplicate email
duplicate external API request
duplicate business operation
Idempotency is about making the operation itself safe to repeat.
Database constraints are one of the mechanisms that help enforce data integrity.
Good systems usually need both.

A Practical Booking Architecture
A production booking system can follow a flow similar to:
Client
|
| POST /bookings
| Idempotency-Key
↓
API Server
|
↓
Validate Request
|
↓
Check Idempotency
|
├── Already Processed
| ↓
| Return Existing Result
|
└── New Request
↓
Database Transaction
↓
Check Availability
↓
Create Reservation
↓
Store Idempotency Result
↓
Commit
↓
Return Response
This approach becomes particularly useful when a system handles reservations, payments and inventory simultaneously.

What About Redis?
For high-traffic systems, Redis can also be used for temporary idempotency state.
For example:
SET idempotency:ABC123 processing NX EX 300
The NX option means the key is only created if it doesn't already exist.
This can help coordinate concurrent requests.
However, Redis should not automatically replace your database as the source of truth.
The right architecture depends on:

  • Transaction requirements
  • Failure scenarios
  • Request volume
  • Data retention
  • Consistency requirements
  • Distributed system architecture

Testing Idempotency
Don't just test the happy path.
Test:
Same request twice
Request A → Success
Request A → Same result
Same request simultaneously
Request A ─┐
├→ Backend
Request A ─┘
Only one booking should be created.
Same key with different payload
Key: ABC123
Payload: Room 101

Key: ABC123
Payload: Room 205
The second request should be rejected.
Server failure
Simulate a failure after the database operation but before the response reaches the client.
Then retry the request.
The system should not create a second booking.

The Bigger Lesson
Idempotency isn't just a feature for payment APIs.
It is a general backend design principle.
Whenever an API performs an operation that should happen only once, ask:
What happens if this request arrives twice?

If the answer is:
"It will create another record."

then the endpoint probably needs better protection.
For SaaS applications, especially systems handling bookings, payments, orders or inventory, designing APIs for safe retries can prevent some very difficult production bugs.

Final Thoughts
Users will double-click.
Networks will fail.
Requests will time out.
Clients will retry.
Servers will restart.
These aren't unusual edge cases. They are normal conditions in distributed applications.
A robust backend should be designed with these realities in mind.
Idempotency gives APIs a way to safely handle repeated requests without accidentally repeating the business operation.
For applications such as hotel management platforms, where a single duplicate request can affect reservations, room availability, payments and guest communication, this small architectural decision can make a significant difference.
Build APIs assuming requests can be repeated — because eventually, they will be.

Top comments (0)