Designing a Hotel Room Reservation System like Booking.com
How I designed and built a hotel booking system with microservices: consistent inventory, payment holds, outbox and the last room problem.
This article was translated from Spanish using AI. The original, written by the author, is here: https://ghost.joel-uzcategui.com/diseno-sistema-reservas-booking/
In this article I am going to explain the design of a microservices-based system whose main goal is to book hotel rooms the way booking.com does. I consider this design interesting because it is not a simple CRUD, since it has to handle special cases where a normal CRUD fails, such as when a single room is left during high season and more than one person tries to book that same room. That problem produces duplicate card charges for the same room, which leaves you dealing with refunds and angry customers.
A booking system needs to be able to handle concurrency under heavy load, keep its data consistent, and be designed to tolerate failures.
Scope
To start the design it is important to settle a few things:
- Are we going to process payments?
- Booking channels: web only, or a mobile version too? Are we going to accept bookings from external systems?
- Will we accept cancellations, and will we allow overbooking?
- Will we have dynamic pricing?
In our case we are going to integrate Stripe to process payments. We will have a web version only: all booking management happens in our backend, which is called from a website. We will accept cancellations but we are not going to allow overbooking, and to keep the project simple rooms will have a fixed price.
One last clarification about the scope: inventory is loaded by an administrator from a panel, there are no owners publishing their own properties. A two-sided marketplace implies outbound payments, identity verification and commissions, and that is a different project from the one I am interested in here.
Functional requirements
With the scope defined, we can move on to the functional requirements, that is, what the system has to be able to do.
- Catalog and inventory management. An administrator can create hotels and room types, and load how many units are available.
- Search. The user searches for hotels filtering by location, check-in and check-out dates, number of guests and price. Since it is the most frequent operation in the system it has to respond fast, and further on I explain how that is achieved without punishing the database where bookings are written.
- Booking flow. The user can book a room for a set of dates, view their bookings and cancel them. The system must prevent two users from ending up booking the same room.
- Payments. Stripe is integrated as the payment gateway. The charge happens inside the booking flow and its result determines whether the booking is confirmed or discarded.
- Notifications. The user receives confirmation of their booking, and a notice when it is cancelled or refunded. This is the part of the system that can fail or be delayed without affecting the booking itself.
Non-functional requirements
Having defined what the system has to do, now we define how it has to behave. These are the non-functional requirements.
- Strong consistency in the inventory. The rule that can never be broken is that the reserved units of a room type on a given date must not exceed the available units. The rest of the system's data can be a few seconds stale without any harm.
- High concurrency. The system must be able to take a large number of users trying to book simultaneously, even when they are all competing for the same room. The concrete goal I set myself is that two hundred simultaneous requests for the last available unit end up as a single booking.
- Low latency. Searches should take roughly 500 ms, while booking management should take between 2 and 3 seconds.
- High availability. At least 99.9%.
- Horizontal scalability. The system must be able to scale with the traffic it is receiving, and above all it must remain correct while doing so: bringing up a second instance of the booking service cannot open the door to a double booking.
Usage estimation
To size the system I start from real data. According to Booking Holdings' annual report, 1.235 billion nights were booked across its platforms in 2025, which gives me a basis for some calculations.
Assuming an average booking is 2 nights and that the year has 365 days, then:
1.235B nights ÷ 2 = 617.5 million bookings per year
617.5M ÷ 365 days = 1.7 million bookings per day
617.5M ÷ 31,536,000 seconds = ~20 bookings per second on average
With these numbers we can see that not everyone who comes into the system makes a booking: many just come in, check prices, and later come back to check them again until they decide to book or simply do nothing, so the system has to be designed to support that too.

With this funnel I want to show how traffic is distributed. Assuming a 10% conversion at each step, out of 100% of the traffic only 10% reaches the room booking view, and out of that last percentage only 10% makes an actual booking. Following these percentages we can work out:
If we have 20 requests per second to make a booking, then we will have 200 requests at the point of viewing the room booking view.
If 200 requests per second is 10% of the total, then the application as a whole would be receiving 2000 requests per second.
Based on these numbers we can conclude that we are going to have 20 TPS and 2000 QPS, which gives us a system where the load is much heavier on reads than on writes, so the architecture has to be optimized for reading. But there is an important difference between the two: reads are many but they can be a few seconds stale without any harm, if a user sees a room that has just been booked they will simply find out when they try to book it. Writes, on the other hand, are few but they have to be consistent always, because they are what keeps double bookings from happening.
The microservices
With that in mind we can start designing the system. For this project I use NestJS as the framework, and the services I am going to build are seven: six microservices and the api gateway, which I do not count as a microservice because it has no domain of its own and no database.
1. Api gateway. This is the entry point of the system, every request from the browser goes through the api gateway and it talks to the corresponding microservice. It has no database connection at all, it is only connected to RabbitMQ to send messages to the respective microservices. No business logic is processed here, it only validates the token, checks the user's role and decides which microservice each request goes to.
2. Auth. This microservice handles user management and nothing else: it allows registering and authenticating users, and issues the token with the corresponding role. It is important to understand that Auth does not authorize each user action, it only signs the token at the start of the session; authorization happens in two different places, the api gateway checks the role and each microservice checks that the user owns the resource they are trying to modify.
3. Catalog. This is the microservice responsible for managing the descriptive information about hotels: name, city, location, photos, amenities, room types and prices. It is important to clarify that inventory does not live here, that information belongs to the booking microservice for reasons I explain further on. This service is the source of data for two others: neither search nor booking query its database, instead every time a hotel changes catalog emits an event with the hotel's complete state, and from there each one updates its own copy. Sending the whole hotel instead of only what changed is more traffic, but it lets a consumer rebuild itself without needing to know anything about the previous history.
4. Booking. This is the central microservice of the system and it owns the inventory, the temporary bookings and the confirmed bookings. These three things go together because they share a rule that must hold always and immediately: the reserved units of a room type on a given date can never exceed the available units. A rule like this cannot be split across several microservices, because then distributed coordination would be needed to protect it; with everything in the same database it is solved with a local transaction.
5. Payment. As the name says, it talks to the payment platform. When Stripe confirms the charge, this microservice does not call booking directly, instead it emits an event that booking consumes to confirm the booking. The only exception in the whole system is here: the Stripe webhook arrives over HTTP and on top of that it does not even go through the api gateway, it goes straight to payment. The reason is that Stripe signs the exact bytes of the request body so that you can verify the message is theirs, and anything that reserializes it along the way would break that signature. That is why payment is the only microservice with its own exposed HTTP server, and only for that route.
6. Search. This is the microservice in charge of handling the system's searches, and it does not even have a database of its own: its entire state lives in the search engine. It does not query any other microservice's database, instead it keeps its own index, updating it from the events the catalog emits, and this way it can answer fast without searches touching the database where bookings are written.
7. Notification. This is the microservice in charge of sending notifications to the user. In this case they are only emails, two of them specifically: the confirmation of a booking and the notice of a refund, but if there were a mobile application it could send push notifications. It is the only microservice with no API: nobody asks it for anything or waits for its response, it only listens to the system's events and acts.
Supporting services
With the microservices defined, now let's look at which supporting services I use and why.
1. PostgreSQL. This is the source of truth of the system and each microservice has its own database, no microservice can query another's database. I choose it because it is where we are going to guarantee that double bookings do not exist, thanks to being able to use transactions and constraints that the database itself enforces even with several instances of the microservice running at once.
2. RabbitMQ. This handles all the internal communication of the system and I am going to use it in two different ways. On one hand the api gateway sends a message to the corresponding microservice and waits for the response, because there is a user waiting on the other side. And on the other hand microservices emit events to announce that something happened, without waiting for anyone's response, such as when a booking is confirmed and the email has to be sent and the search index updated.
3. Redis. I use it as a cache in a single place: a hotel's public detail page, which is the most expensive query in the catalog because it joins city, amenities, room types and photos, and also the most read, because every visit to the detail page asks for it. It is kept for ten minutes and invalidated explicitly when the hotel changes. I deliberately do not cache availability by date, because that is precisely the data that cannot be stale. And there is a decision I find more important than the cache itself: if Redis is unavailable, all of its operations behave as a cache miss and the query goes to Postgres. A cache should never be a single point of failure, and this way the service keeps working even if Redis goes down.
4. Elasticsearch. This is the search engine of the system. I need it because the user's searches are not simple filters: you have to search by hotel or city name tolerating typos, sort by relevance and filter by several criteria at once. With a catalog that mixes Spanish, French and German cities, having a search for «munich» find «Múnich» is not a luxury. It also lets me separate the read load from the database where bookings are written, and rebuild the entire index without searches stopping: the code never writes against an index with a fixed name but against an alias, so the new index is built in parallel and the switch is a single atomic operation at the end.

The last room problem
With all the pieces defined, it is time to solve the problem I started from: several people booking the last room at the same time.
The first thing is to understand why the seemingly obvious solution fails. The natural approach would be to query how many units are left, check in code that there is availability, and then save the booking, that is, a SELECT to see the inventory, an if to check there is room and an INSERT to create the booking. The problem is that between the query and the write there is a window of time, and other requests can come in during that window. If two hundred people query at the same time, all two hundred are going to read that one room is available, all two hundred are going to pass the check and all two hundred are going to insert their booking. The code is well written, the logic looks correct, and even so the system sells the same room two hundred times.
Before being able to protect availability you have to decide how it is represented, because that decision determines which tools I will have available to protect it. The most intuitive way would be to store each booking as a date range and check whether the range the user asks for overlaps any of the existing ones. It produces few rows and it is easy to understand, but under concurrency it is a fairly hard problem, since checking an overlap forces you to read every booking in the range before deciding, and protecting it requires locking rows that do not exist yet.
The alternative I am going to use is to decompose availability into minimal units, that is, one row per room type and per night, storing how many units exist in total and how many are reserved:
CREATE TABLE inventory (
room_type_id UUID NOT NULL,
date DATE NOT NULL,
total_units INT NOT NULL,
reserved_units INT NOT NULL DEFAULT 0,
PRIMARY KEY (room_type_id, date),
CONSTRAINT no_overbooking
CHECK (reserved_units >= 0 AND reserved_units <= total_units)
);
These rows are not created when booking, they are loaded ahead of time when the administrator creates a room type, and a daily process extends the window so that there is always a year available going forward. This produces quite a lot of rows, around one thousand eight hundred per hotel with five room types and a year loaded, but in exchange it turns the problem into something much more manageable, since booking stops being about checking an overlap and becomes incrementing a counter without exceeding a limit.
And it is important that the row exists before the first booking arrives, because that row is the point every request for that night is going to compete over. If two users had to create it at the same time we would be right back to exactly the same race we are trying to avoid.
With the inventory represented this way, booking is resolved in a single statement:
UPDATE inventory
SET reserved_units = reserved_units + :units
WHERE room_type_id = :roomTypeId
AND date = :date
AND reserved_units + :units <= total_units;
This query runs once for each night of the stay, all within the same transaction. If any of them returns zero affected rows it means that night no longer has availability, so the whole transaction is rolled back and the user is told there are no rooms left.
The difference from the previous version is that here there is no separation between reading and writing, the condition and the update happen in the same atomic operation, and PostgreSQL makes sure two transactions cannot modify the same row at once. The second one waits for the first to finish and re-evaluates the condition with the already updated value, so if the first one used up the last unit, the second simply does not meet the condition and modifies nothing.
This is what makes the solution work in production. If the protection were in the microservice's code, with an in-memory lock or a variable, it would work with a single instance and stop working as soon as you brought up the second one, because each process has its own memory and neither knows what the other is doing. And bringing up replicas is precisely what you have to do when traffic goes up. With the guarantee living in the database it does not matter how many instances there are: they all compete for the same row, and that row can only be modified by one at a time.
The table's CHECK constraint plays a different role here than it appears to. It is not there to validate data, it is there as a last line of defense: even if there were a bug in the code, or someone ran a query by hand, the database would reject any row where the reserved units exceed the total. It is the only guarantee still standing when everything else fails.
Now, solving the problem in the database guarantees there are no double bookings, but it says nothing about what the user sees, and that has to be designed too. With this strategy the requests that lose the race do not fail. There is no exception and no aborted transaction, the UPDATE simply does not find any row meeting the condition and updates zero rows, so there is nothing to retry. The service translates that into a response saying there are no rooms left, which is exactly what the user needs to know in order to try other dates. What does need care is that this case is never confused with a system error: whoever loses the race has to be told there is no availability, not handed an internal error, because that would tell them the application is broken when in fact it is working exactly as it should.
The room while the user pays
So far I have talked about booking as if it were a single operation, but with payments in the middle it is not. Several minutes pass between the user pressing the button and the money arriving, and you have to decide what happens to the room during that time.
Waiting for the payment to take the inventory does not work: two people would reach the gateway for the last room and one would pay for something that no longer exists. Leaving it taken forever does not work either, because most bookings that get started are never finished.
What I do is take the inventory as soon as the user confirms the dates, and create the booking in an intermediate state, held, which expires after fifteen minutes. The room is theirs while they pay, and whoever does not get the hold finds out before reaching the payment step.
This is why the hold exists: the charge happens outside the transaction. If I called the gateway with an open transaction, the inventory row would stay locked for as long as the user takes to pay. With the hold, the transaction lasts milliseconds and the payment happens afterwards.
The inverse problem is abandoned bookings. A process every thirty seconds expires the stale holds and returns the inventory. And it releases the same way as the inventory does: the expected state goes inside the condition of the update, so if the payment confirmed the booking in the meantime, no row is affected and the inventory is not released.
The payment that arrives late
There is a less obvious case. The user takes sixteen minutes to pay. The hold expires, the cleanup process releases the inventory, and then the payment confirmation arrives.
There is money charged and an expired booking. Keeping the money without giving the room is not an option. But just confirming does not work either: during the minute the inventory was free another customer could have come in, and if I confirm without checking I create the very double booking the whole system avoids.
What I do is try to recover it, night by night, with the same conditional UPDATE. If every night still has room, the booking is confirmed and the customer never notices a thing. If any of them fails, the nights already recovered are rolled back and an automatic refund is issued.
In both cases the result is correct: the system never keeps money for a booking it cannot deliver, nor confirms a room it no longer has.
Making sure the event is not lost
This part is not about concurrency but about communication between services.
When confirming a booking two things have to happen: change its state and tell the other services. These are two different systems, the database and the broker, and they cannot be written to atomically. If I confirm and the publish then fails, nobody finds out. If I publish first and the transaction is rolled back, the email for a booking that does not exist gets sent.
The solution is not to publish directly. The event is written to a table in the same database, inside the same transaction, so either both things are saved or neither is. A separate process reads that table and publishes what is pending. If it fails halfway, the event is still there and it is retried.
The worst that can happen then is that an event gets published twice, and that is going to happen anyway: a broker guarantees at-least-once delivery, not exactly-once. So every processed event leaves its identifier as a primary key in a table, inserting it in the same transaction as the work. If it was already there, the insert violates the key, the whole transaction is rolled back and the duplicate has no effect. Deduplication is done by a unique index, not by a prior check that could have a race of its own.
The same pattern in four places
The same idea shows up in four places: when reserving inventory, when expiring a hold, when recovering a late payment and when claiming pending events. Always the same thing: instead of reading, deciding in code and writing, the condition goes inside the write and it is the database that decides who wins.
You can see it running
The system is deployed at stays.joel-uzcategui.com. You can search, book and pay with a test card.
And the code is on GitHub, split across two repositories:
- stays-launcher — the orchestrator. It holds no application code: it contains the docker-compose, the development infrastructure and the submodules pointing at each microservice's repository.
- stays-web — the frontend, built with Next.js.