This is the case study where nothing is hypothetical. Every code sample below is taken from StayHub, a working Airbnb-style booking application — FastAPI and Postgres for writes, Elasticsearch for search, Redis for caching and rate limiting, a transactional outbox for asynchronous work — and every number quoted was measured on it.
The point of doing it this way is that a booking system’s hard parts are not the ones a diagram shows. They are in the transaction boundaries, and those only become concrete when there is code.
Step 1 — Scope
IN SCOPE OUT OF SCOPE (say so)
search listings messaging between guest and host
view a listing reviews and ratings pipeline
check availability dynamic/surge pricing
book and pay identity verification, insurance
cancel (rules apply) a mobile app
hosts create listings
WORTH ASKING
can two guests book the same
dates? -> NO. This is the whole system.
cancellation policy? -> a business rule, and it must live in ONE place
when is money taken? -> at booking, or at check-in? changes the flow
instant book, or host approval? -> changes booking from 1 step to 2
NON-FUNCTIONAL
search must be fast it is the browsing experience
bookings must be correct a double booking is a real-world incident
read-heavy, ~200:1
availability > consistency
for BROWSING a stale listing is fine
consistency > availability
for BOOKING refusing beats double-bookingThat last pair is the sentence to say out loud. The same product wants opposite answers on the CAP question depending on which operation is running, which is exactly the point the consistency post makes: the model is a property of the operation, not the system.
Step 2 — Estimate
ASSUME 500k daily active users · 2M listings
each user views ~20 listings, searches ~5 times
1 in 50 visits ends in a booking
READS 500k x 20 / 100k = 100 listing views/sec peak x3 = 300/sec
500k x 5 / 100k = 25 searches/sec peak x3 = 75/sec
WRITES 500k / 50 = 10k bookings/day / 100k
= 0.1 bookings/sec peak x3 = 0.3/sec
STORAGE 2M listings x 2 KB = 4 GB <- the entire catalog fits in memory
10k bookings/day x 1 KB x 365 = ~3.6 GB/yearRead those results before moving on, because they say something surprising.
0.3 bookings per second at peak. That is not a throughput problem in any sense. Nothing about the write path needs sharding, queuing or partitioning — and yet the booking path is where all the engineering goes, because the problem is correctness. Two people booking the same room at the same instant is a disaster at 0.3 writes a second exactly as much as at 3,000.
4 GB of listings fits in memory. The entire catalog can be cached, which makes the read design much simpler than it would otherwise be.
75 searches/sec across 2 million listings is the read problem, and it is not one SQL does well. That is what justifies a search index — arithmetic, not habit.
Step 3 — The shape
┌── writes ──> FastAPI ──> Postgres
React apps ───┤ │
├── reads ──> Hasura ─────────┘
└── search ──> FastAPI ──> Elasticsearch
▲
sunk in application code from every write pathThe split is the lesson. Every create, update and delete goes through one service, which is what makes server-side pricing, the availability check and the cancellation rule impossible to bypass. Reads come from a GraphQL layer with row-level permissions, with one deliberate exception: search, because the entire point of maintaining an index is to answer that question without touching Postgres.
One JWT, signed by the write service and verified by the read layer with a shared secret. One login, two APIs, no session store — which is what makes the tier stateless.
The property this split buys is worth naming, because it is the reason to accept its complexity: no role has insert, update or delete permission in the read layer. Not “the frontend does not use them” — they do not exist. That is what makes server-side pricing and the availability rules genuinely unbypassable rather than merely conventional, and it is a much stronger guarantee than a code review can give.
Step 4 — The schema
CREATE TABLE properties (
id BIGSERIAL PRIMARY KEY, -- internal: small, fast joins
public_id UUID NOT NULL UNIQUE, -- the ONLY id that leaves the process
host_id BIGINT NOT NULL REFERENCES users(id),
status VARCHAR(16) NOT NULL, -- DRAFT | PUBLISHED | SUSPENDED
price_per_night NUMERIC(10,2) NOT NULL,
cleaning_fee NUMERIC(10,2) NOT NULL,
max_guests INTEGER NOT NULL,
rating_average NUMERIC(3,2) NOT NULL DEFAULT 0, -- denormalised
deleted BOOLEAN NOT NULL DEFAULT false -- soft delete
);
CREATE TABLE bookings (
id BIGSERIAL PRIMARY KEY,
public_id UUID NOT NULL UNIQUE,
property_id BIGINT NOT NULL REFERENCES properties(id),
guest_id BIGINT NOT NULL REFERENCES users(id),
check_in DATE NOT NULL,
check_out DATE NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING|CONFIRMED|CANCELLED|COMPLETED
nightly_rate NUMERIC(10,2) NOT NULL, -- captured at booking time
total NUMERIC(10,2) NOT NULL
);Four decisions in there recur across the whole track.
Two ids per row. A BIGSERIAL for foreign keys and joins, and a
UUID that is the only id ever appearing in a URL. A sequential id in a URL tells the world how many
bookings you have and invites /bookings/1, /bookings/2 — the unique id post covers why.
Money is NUMERIC, never a float. Binary floating point cannot
represent 0.10 exactly, and the errors accumulate. This is not a style preference.
Prices are captured on the booking. nightly_rate and
total are stored, not recomputed from the listing — because a host raising their
price next week must not change what a guest already agreed to pay.
Deletes are soft. A booking references a property and a user forever, so those rows are flagged rather than removed.
Step 5 — The booking race
The core of the system. The natural implementation is check-then-write, and it is wrong:
guest A: is 5-7 Jan free? ─> YES ──────────> INSERT ✓
guest B: is 5-7 Jan free? ─> YES ──────────> INSERT ✓
Both checks ran before either insert landed.A transaction does not fix it — at Postgres’s default isolation level this is a phantom read, which read committed does not promise to prevent. The fix is to make the overlap impossible in the only place that can see both writes:
ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlapping_bookings
EXCLUDE USING gist (
property_id WITH =,
daterange(check_in, check_out, '[)') WITH &&
) WHERE (status IN ('PENDING', 'CONFIRMED', 'COMPLETED'));Two details worth stealing. The range is '[)', so a booking ending on the 5th and
one starting on the 5th do not overlap — which is exactly how hotel nights work, and using
<= instead would reject every legitimate back-to-back booking. And the
WHERE clause means cancelling frees the dates automatically, with no cleanup job.
The application keeps a friendly check as well, and is explicit that it is not the guard:
if self.bookings.overlapping(prop.id, req.check_in, req.check_out):
raise ConflictException("Those dates are no longer available.")That catches the common case — dates already taken when the request arrived — and produces a good message. It loses the race, deliberately. The constraint wins it, and the application translates the verdict:
except IntegrityError as exc:
self.db.rollback()
if _is_overlap_violation(exc):
raise ConflictException(
"Those dates were just booked by someone else."
) from exc
raiseNote it checks which constraint fired. Treating every integrity error as “dates taken” would report a broken foreign key as a booking clash — turning a real bug into a plausible business message nobody investigates.
The hold, for free
PENDING is in the constraint’s status list, so a booking awaiting payment
blocks the calendar by the same mechanism that prevents double booking. No separate hold table, no
lock, no timer.
PENDING dates held, not yet paid ─┐
CONFIRMED paid ├─ block the calendar
COMPLETED stay finished ─┘
CANCELLED ─────────────────────────────── does not block
Release is a status change, not a deletion.What it does not solve is expiry: a guest who abandons checkout holds those dates indefinitely. That needs a scheduled job cancelling stale pending bookings — and that job must run on exactly one server, which is one of the few legitimate uses for a distributed lock.
What the booking transaction actually contains
Worth listing, because the boundary is the design:
BEGIN
validate: is the property bookable, does it sleep this many, is the
guest not the host?
friendly overlap check (loses the race, on purpose)
compute the price server-side
INSERT booking (PENDING) <- the constraint arbitrates here
INSERT outbox ('booking.created') <- the email, atomically
COMMIT
│
└── AFTER the commit: index, invalidate cacheThree things are inside and two are outside, and each placement is a decision.
The outbox row is inside, so the booking and the instruction to email commit together. It also means a booking rejected by the constraint takes its notification with it — nobody is emailed about a reservation that lost the race, and that is free rather than coded.
Indexing and cache invalidation are outside, because they touch systems that cannot participate in a Postgres transaction and must not be able to fail a booking.
Nothing external is called inside. No payment provider, no email, no HTTP at all — because a transaction holding locks while waiting on somebody else’s network is how a slow third party becomes your deadlock.
Step 6 — Pricing is a security boundary
The request body carries no amount. Ever.
breakdown = pricing_service.quote(prop, req.check_in, req.check_out)The server recomputes every figure from the listing. If the client sent a price, a modified request would book a $2,000 stay for $1 — and the fact that your own frontend would never do that is irrelevant, because the attacker is not using your frontend.
The same logic makes registration always create a customer, whatever the body says, and makes foreign-owned resources return 404 rather than 403 — a 403 confirms the id exists, which on a guessable identifier is a slow read of your table.
Step 7 — Search
75 searches a second over 2 million listings with text matching and a dozen optional filters. Postgres can do it; it cannot do it well, and every filter combination would want its own index.
writes ──> [ Postgres ] source of truth
│
│ sunk from application code, AFTER the commit
v
[ Elasticsearch ] derived, disposable, rebuildableThe sink is two lines, and their position is load-bearing:
def _sync(self, prop: Property) -> None:
indexed = indexer.index_property(prop)
cache.invalidate(cache.property_key(prop.public_id))Called after the commit. Indexing first means a rolled-back transaction leaves a listing in the search results that does not exist, and the guest who clicks it gets a 404 from a page that just offered it. Indexing inside the transaction is worse still — a slow search cluster would then fail a host’s save.
That leaves a gap: crash between the commit and the index write and the two diverge. It is survivable because the index is derived data with a rebuild path, and because a failed index write queues a retry through the outbox. The consistency post works through exactly this.
One deliberate limitation worth stating: date availability is not in the index. Availability lives in the bookings table, so filtering on it at search time would mean either denormalising every booking into the document or a second query per hit. The listing page checks properly. Saying so beats a filter that quietly does nothing.
Step 8 — The read path
The listing page is the hottest read, and it earns a cache on all three counts: hot, expensive (it joins images, amenities and the host), and rarely written.
key = cache.property_key(public_id)
cached = cache.get_json(key)
if cached is not None:
return PropertyResponse.model_validate(cached)
view = PropertyResponse.model_validate(self.get_for_public(public_id))
cache.set_json(key, view.model_dump(mode="json"), settings.cache_ttl_property_seconds)
return viewMeasured on the machine that wrote this: 15.2ms cold, 2.0ms warm over HTTP; 8.8ms to 0.3ms at the service layer.
Invalidation hangs off _sync, which every write path already calls — so a new
write path that forgets it does not merely serve a stale cache, it also fails to appear in search,
which somebody notices within minutes. Coupling the silent failure to the loud one is deliberate.
And the cache is optional: every call returns “miss” on failure rather than raising. The suite proves it — 165 tests pass with Redis running; with it stopped, 142 pass and 23 skip, and nothing fails.
What the index holds
The document is a denormalised snapshot — everything a search result card needs, and nothing else:
{
publicId, title, city, state, country,
propertyType, roomType,
pricePerNight, cleaningFee,
maxGuests, bedrooms, beds, bathrooms,
ratingAverage, ratingCount,
coverImageUrl, amenities[]
}
Denormalised ON PURPOSE: rendering a result page must not
require a single query against Postgres.That is the read-model idea in its clearest form. The index is not a copy of the properties table; it is a copy of the answer to one question, shaped so the question is cheap. Host name, address and internal ids are absent because a result card does not show them.
The document id is the property’s public id, which is what makes indexing idempotent — running it five times produces the same index as running it once, so at-least-once retries from the outbox are safe by construction.
One more decision worth naming: unpublished listings are deleted from the index rather than flagged. Filtering on a status field at query time would work, and one forgotten filter leaks a draft into public results. Absent cannot leak.
Step 9 — Payment
The step that spans a boundary you do not control, so it cannot be a transaction.
1 create booking PENDING dates are held by the constraint
2 create a payment intent with the provider
3 client confirms payment in the browser, with the provider directly
4 provider webhook -> mark CONFIRMED
The WEBHOOK is the source of truth, not the browser.Step 4 is the one people get wrong. A browser telling your server “payment succeeded” is a claim from an untrusted client. The webhook comes from the provider and carries a signature — and since the provider has no token of yours, that signature IS the authentication.
Card details never reach your servers. You store the provider’s identifier plus brand and
last four digits for display, and nothing else. If a card_number column exists
anywhere, something has gone badly wrong.
Step 10 — Cancellation
A business rule, and the interesting part is where it lives.
if actor.role != "ADMIN" and not is_cancellable(booking.status, booking.check_in):
deadline = cancellation_deadline(booking.check_in)
raise ApiException(
f"This booking can no longer be cancelled — the deadline was {deadline:%d %b %Y}, "
f"{settings.cancellation_cutoff_days} days before check-in."
)The rule is in its own module, dependency-free, because two layers need it: the service enforces
it, and the response schema reports isCancellable so the UI can hide the button.
Duplicating it would mean a UI that disagrees with the server.
Enforcing it server-side regardless of what the UI shows is the point. A hidden button is a courtesy; anyone can post to the endpoint.
Step 11 — Availability, the read side
Booking correctness is one half. The other is showing a calendar before anyone commits, and it is a different query with different requirements.
def blocked_ranges(self, property_id: int, *, from_date: date) -> list[Booking]:The picker greys out taken dates, and it reads from the primary rather than a replica — because a calendar that is 200 milliseconds stale offers dates that were just taken, and the guest then gets a 409 on a date the UI said was free. That is a worse experience than a slightly slower calendar.
It is also deliberately not cached, which is the exception to everything in the caching post. Availability is the one value where stale means wrong: serving a five-minute-old “those dates are free” is not a performance win, it is a failed booking at best.
The general rule this illustrates: cache what is expensive to compute and cheap to be wrong about. A listing description is both. An availability calendar is neither — it is a cheap indexed range query, and being wrong about it costs a conversion.
The overlap test itself is worth reading carefully, because the boundary is where these queries go wrong:
existing.check_in < new.check_out AND existing.check_out > new.check_in
strictly less-than on both sides, so:
existing |----5th----6th----7th|
new |----7th----8th|
^ no overlap. Correct.
Using <= would reject every back-to-back booking there is.Step 12 — Scaling it up
The estimate said this system does not need much. If the numbers were ten or a hundred times larger:
| Multiply by | What changes |
|---|---|
| 10x | Read replicas for listing reads. Cache the whole catalog — it is still only 40 GB. Booking writes are 3/sec: still nothing. |
| 100x | Elasticsearch cluster properly sharded. Regional read replicas and CDN for images. Bookings at 30/sec — one primary still handles it, and the constraint still holds. |
| 1000x | Now shard bookings, by property_id, so a property’s
calendar and its constraint stay on one shard. This is the key choice: shard by guest
instead and the exclusion constraint stops working, because the rows it must compare land
on different machines. |
That last row is the most interesting scaling consequence in this post. The correctness mechanism constrains the shard key — a constraint can only enforce an invariant across rows that live in the same database. Sharding by the wrong key does not slow the system down, it silently removes the guarantee.
Failure modes
| What fails | Effect | Why it is survivable |
|---|---|---|
| Redis | Listing pages ~7x slower | Every call degrades to a miss. Tested both ways: 165 pass with it, 142 pass and 23 skip without. |
| Elasticsearch | Search returns 503; everything else works | Startup does not block on it, and browsing by link is unaffected. The index is rebuildable from Postgres. |
| The outbox worker | Confirmation emails stop; bookings continue | Messages accumulate as PENDING. Stopping the worker is safe — that is what distinguishes a queue from a background task. |
| Payment provider | Bookings are created and held PENDING; payment cannot complete | The dates are still held by the constraint, and the checkout page says payment is unavailable rather than failing obscurely. |
| Postgres primary | The real outage — no bookings, no availability | Promote a replica. This is the one component with no graceful degradation, which is why it gets the standby. |
The pattern is the one worth articulating: everything except Postgres degrades to slower or partially unavailable, and Postgres is the only true outage. That is not an accident — it is the result of deciding, for each dependency, what happens when it is gone, and then writing the code that way.
What an interviewer will push on
- “How do you prevent double booking?” — a database exclusion constraint, not application logic. Explain why check-then-write fails and why a transaction alone does not save it.
- “How do you hold dates during checkout?” — a PENDING booking, blocked by the same constraint. Plus a job to expire abandoned ones.
- “How is search kept in sync?” — sunk from application code after the commit; derived data with a rebuild path; a failed write queues a retry.
- “What if Redis dies?” — slower, not broken, and there is a test suite that runs both ways.
- “Where does pricing happen?” — server-side, always. The request body never carries an amount.
- “How do you know payment succeeded?” — the signed webhook, never the browser.
- “How would you shard?” — by property id, because the correctness constraint requires the competing rows to be co-located.
If there is one thing to take from this case study, it is the shape of the answer rather than any individual mechanism: the estimate said throughput was trivial, so every design decision was about correctness under concurrency and what happens when a dependency is gone. Those two questions produce a better system than “how do we handle the load”, and they are the two most system designs skip.
Next: designing Amazon, where the same inventory problem appears at a scale that breaks every answer in this post.