Topmate HLD: Booking Paid 1:1 Sessions Without Double-Booking
How a creator-session platform works inside: turning availability windows into bookable slots, the interval-overlap test that prevents double-booking, and the hold-pay-confirm flow.
A creator on Topmate (or a mentor on Preplaced, ADPList, Calendly) says "I'm free Tuesday mornings," and strangers book paid 1:1 slots without anyone ever ending up double-booked. The product looks like a calendar, but the engineering question is sharp and small: given a mentor's availability windows and the slots already taken, what can still be booked — and how do you make sure two people racing for the same 10 a.m. can't both win? It all reduces to interval arithmetic and one carefully-placed lock.
This is the inside of Topmate / Calendly / any session-booking tool. The signature problem is conflict-free booking: offer open slots, and never sell the same one twice.
Let's start nowhere near a computer
Think of a barber with a sign-up sheet on the wall. The sheet shows the hours they're open — say 9 to 12 — ruled into one-hour lines. A walk-in scans for an empty line and writes their name. The barber's only real rule: two names can't share a line. If someone tries to squeeze "9:30" between the 9:00 and 10:00 slots, it clashes with the 9:00 appointment that already runs to 10:00, so it's refused. And critically — only one pen, so two customers can't write on the same line at the same instant.
The open hours are availability windows, each ruled line is a slot, "two names can't share a line" is overlap detection, and "only one pen" is the lock during checkout. That's the whole system.
Where this exact shape shows up
- Topmate, Calendly, Cal.com, ADPList, Preplaced — all are availability-minus-bookings with conflict checks.
- The same interval-overlap logic powers hotel/room booking and calendar free-busy.
- "Hold the resource during checkout" is the identical instinct to movie-seat locking.
Step 1 — Functional requirements (sentences first)
- A creator publishes availability (recurring or one-off windows).
- The platform shows open slots to a visitor.
- A visitor books a slot, pays, and both parties get a confirmation.
- No double-booking — a slot can be sold at most once.
- Handle timezones so 10 a.m. for the creator is shown correctly to a visitor abroad.
The load-bearing requirement is no-double-booking. It's what forces an overlap check plus a lock, not a naive "mark it taken."
Step 2 — Non-functional requirements
Features say what to build; the non-functional requirements say how well, and they are what actually shape the design. Named with the canonical terms so you recognise them in the room:
- Strong consistency (the booking). Even under a race for the same slot, at most one booking wins — a seat sold twice is a refund, an apology, and a lost creator. This one is non-negotiable and it decides the datastore.
- Eventual consistency (browsing). The open-slot list a visitor browses may be a second stale — the truth is re-checked at hold time — so it can be cached and served fast. Knowing which reads may lag is half the design.
- Low latency. Browsing availability and placing a hold feel instant (tens of milliseconds); confirming can take a beat longer but stays bounded.
- High availability. Booking is revenue, so it survives a creator's launch spike; browsing may degrade to a slightly stale list rather than fail outright.
- Durability. A confirmed booking and its payment can never be lost; a hold is ephemeral by design and may be dropped freely.
- Scalability. Huge-N creators, small-N per creator — millions of independent calendars, each with modest traffic, plus the occasional launch that stampedes one calendar.
- Timezone correctness. A creator's 10 a.m. shows correctly to a visitor abroad, and stays correct across a daylight-saving shift.
Listing them is the easy half; the design only earns them if it fulfills them. Here is the contract this design signs — each requirement, the one mechanism that keeps it, and the step that cashes it:
| Requirement | How this design fulfills it |
|---|---|
| Strong consistency (booking) | an overlap test rejects clashes, a UNIQUE(creator, slot) serializes racers — Steps 4, 7 |
| Eventual consistency (browse) | open-slot lists are cached per creator, re-checked against the DB at hold — Step 11 |
| Money/slot consistency | hold → pay → confirm: held during checkout, confirmed only on payment — Step 8 |
| Low latency | open slots are a cheap interval computation, then cached per creator — Steps 4, 11 |
| High availability | stateless services; SQL replica failover; browse degrades, booking fails fast — Step 12 |
| Durability | bookings + payments in SQL; holds are disposable, expiring on a TTL — Step 3 |
| Timezone correctness | store the rule in local time + zone, freeze each booked slot as a UTC instant — Steps 3, 9 |
| Scalability | creators are independent → shard by creator id — Step 11 |
Every trade-off below is chosen to keep one of these — and we point back at this table when we do.
Step 3 — Nouns and the data model
Circle the nouns: creator, availability rule, booking, payment. Now the one rookies miss, because it is a lifetime, not a thing you would name first — the slot_hold: a short-lived claim on a slot with an expires_at. It is the hidden noun the whole race is about. And the noun that isn't a table: a slot itself is never stored — it is computed on demand as availability minus bookings (Step 4), so a creator who shifts a window doesn't leave a table of stale rows to migrate.
creators (id, name, timezone) -- IANA zone, e.g. America/New_York
availability_rules (id, creator_id → creators, weekday|date,
start_min, end_min, slot_len, buffer_min,
valid_from, valid_to) -- recurring OR one-off; local wall-clock
slot_holds (id, creator_id → creators, slot_start_utc,
visitor_id, expires_at) -- the pin; UNIQUE(creator_id, slot_start_utc)
bookings (id, creator_id → creators, visitor_id,
slot_start_utc, slot_end_utc,
status, price_paise, created_at) -- UNIQUE(creator_id, slot_start_utc) on CONFIRMED
payments (id, booking_id → bookings, gateway_ref,
amount_paise, status, idempotency_key)Three details an interviewer rewards. price_paise is snapshotted onto the booking as a long — never a float, never a live lookup — so a creator raising their rate tomorrow can't re-price a session sold today. The availability rule stores local wall-clock time plus the creator's zone, not an absolute instant, so a recurring "9 a.m. every Tuesday" tracks daylight-saving on its own (Step 9). And the UNIQUE(creator_id, slot_start_utc) on both slot_holds and confirmed bookings is the single line that makes double-booking physically impossible — it is doing more work than any code in the article.
Which datastore — and why it isn't a default. Don't say "a database" and move on; the access patterns choose the store. The bookings and payments want ACID transactions and that UNIQUE constraint — the exact guarantees a relational SQL database (Postgres / MySQL) hands you and a document store makes you rebuild by hand, so the strong-consistency requirement picks SQL, not habit. The two things that don't fit relational rows get their own home: the ephemeral holds want native TTL expiry, and the open-slot lists are hot, cacheable reads — both go to Redis (a hold is a key that deletes itself; a slot list is a value with a short TTL). Right tool per job: SQL for the money and the seats, Redis for the timers and the hot reads.
Step 4 — Slots = availability minus bookings
The model is pure interval arithmetic. A creator's availability is a set of windows; bookable slots are fixed-length intervals carved out of those windows, minus anything already booked. Two half-open intervals [a, b) and [c, d) overlap exactly when a < d and c < b — that single comparison is the heart of the whole system.
Getting the half-open convention right matters: a slot ending at 10:00 and one starting at 10:00 touch but don't overlap, so back-to-back bookings are allowed while genuine clashes are caught.
Step 5 — Verbs become APIs
The verbs — browse, hold, confirm — become endpoints. The one split that matters is that holding and paying are two calls, not one:
GET /creators/{id}/slots?date=..&tz=.. open slots, rendered in the viewer's zone (cacheable, short TTL)
POST /creators/{id}/holds place a hold { slot_start } → { hold_id, expires_at }
POST /bookings confirm + pay { hold_id } header: Idempotency-Key: <uuid>
GET /bookings/{id} your booking (auth: owner)The hold reserves the slot before the slow, failure-prone payment step — so you never charge for a slot you might lose to the race, and never hold a slot hostage during a 30-second card timeout. The Idempotency-Key on POST /bookings makes a retried confirm (double-tap, a webhook that fires twice, a flaky-network resend) charge once and book once instead of twice.
Step 6 — The booking engine
Here's the core. It generates open slots from windows and accepts a booking only if it fits a window and overlaps no existing booking:
package dev.fiveyear.slots;
import java.util.ArrayList;
import java.util.List;
/**
* The booking core behind a creator-session platform like Topmate: a mentor publishes
* availability windows, the platform offers bookable slots inside them, and a booking
* must (1) fit entirely within an availability window and (2) not overlap any existing
* booking. The whole thing reduces to interval arithmetic: two half-open intervals
* [a,b) and [c,d) overlap iff a < d AND c < b. Getting that overlap test right is what
* prevents double-booking — the cardinal sin of any scheduling system. Times here are
* absolute minutes; timezone conversion happens at the edge, before this layer.
*/
public class SlotBook {
/** A half-open time interval [start, end) in absolute minutes. */
public static final class Interval {
public final int start, end;
public Interval(int start, int end) { this.start = start; this.end = end; }
boolean overlaps(Interval o) { return start < o.end && o.start < end; }
boolean contains(Interval o) { return start <= o.start && o.end <= end; }
}
private final List<Interval> availability = new ArrayList<>();
private final List<Interval> bookings = new ArrayList<>();
/** Publish a window the mentor is free, e.g. 9:00–12:00 -> [540, 720). */
public void addAvailability(int start, int end) {
if (end <= start) throw new IllegalArgumentException("empty window");
availability.add(new Interval(start, end));
}
/** Bookable slots of the given length: every aligned slot inside a window that isn't already taken. */
public List<Interval> openSlots(int duration) {
List<Interval> out = new ArrayList<>();
for (Interval w : availability) {
for (int s = w.start; s + duration <= w.end; s += duration) {
Interval slot = new Interval(s, s + duration);
if (!overlapsAnyBooking(slot)) out.add(slot);
}
}
return out;
}
/** Atomically book [start, start+duration): accepted only if it fits a window and clashes with nothing. */
public boolean book(int start, int duration) {
if (duration <= 0) return false;
Interval req = new Interval(start, start + duration);
if (!fitsSomeWindow(req)) return false; // outside published availability
if (overlapsAnyBooking(req)) return false; // would double-book
bookings.add(req);
return true;
}
public int bookingCount() { return bookings.size(); }
private boolean fitsSomeWindow(Interval req) {
for (Interval w : availability) if (w.contains(req)) return true;
return false;
}
private boolean overlapsAnyBooking(Interval req) {
for (Interval b : bookings) if (b.overlaps(req)) return true;
return false;
}
}book enforces both rules in two lines: the request must fit inside a published window (you can't book when the creator isn't free), and it must clash with nothing (no double-booking). Everything else — recurring availability, buffers between sessions — is sugar layered on this interval core.
Step 7 — The race, and why code alone isn't enough
The book method above is correct in a single thread, but a real platform has two visitors hitting the same 10 a.m. slot across different servers. Both read "no overlap," both proceed, both write — the classic check-then-act race you've met in every concurrency article. In one process a synchronized block welds it shut; across many servers there is no shared lock, so you push the check-and-set into the datastore as one atomic write, exactly where the UNIQUE(creator_id, slot_start_utc) from Step 3 lives:
INSERT INTO slot_holds (creator_id, slot_start_utc, visitor_id, expires_at)
VALUES (:creator, :slot, :visitor, :exp)
ON CONFLICT (creator_id, slot_start_utc) DO UPDATE
SET visitor_id = EXCLUDED.visitor_id, expires_at = EXCLUDED.expires_at
WHERE slot_holds.expires_at < :now; -- take over ONLY an already-expired holdTwo visitors run this at once and the database serializes them: one INSERT lands, the other hits the unique index and is told "just taken, pick another" — instantly, with no application lock. The subtle part is the ON CONFLICT … WHERE expires_at < :now: a plain unique constraint would let an abandoned hold block the slot forever, so the second visitor is allowed to take the row over only if the existing pin has already lapsed. That is lazy expiry — no cron sweeping stale holds, no gap; the pin "falls out" the instant the next person reaches for the slot, and the row's own atomicity is the referee. The interval test prevents logical clashes; this single indexed write prevents the race, and buys strong consistency without a network lock — correctness with the low-latency requirement intact.
Step 8 — The architecture: hold → pay → confirm
Booking is three phases, and the timeline down the lifelines shows why they are separate:
Hold: the booking service runs the atomic INSERT above — first writer wins, the second is rejected in a millisecond, killing the race. Pay: the held slot goes to checkout while the pin's TTL ticks. Confirm: only after payment clears is the booking INSERTed as permanent (its own UNIQUE a final backstop), both calendars updated, and notifications queued off the request path. Order the steps so the charge is second-to-last: a crash before charging just lets the hold expire harmlessly, and the charge-succeeds-but-booking-write-fails case is healed by the idempotency key plus a reconciliation sweep that either completes the booking or refunds the dangling charge — the orphaned-payment fix in full. Never confirm a paid resource before the money lands; never let an abandoned checkout block a slot forever.
Step 9 — Timezones done right
The trap is "just store UTC." That's right for a booked slot — a confirmed session is one absolute instant, frozen, so a creator in New York and a visitor in Berlin always agree on the moment. But it is wrong for a recurring rule: "9 a.m. every Tuesday" is a wall-clock intention, and across a daylight-saving boundary the same 9 a.m. local maps to a different UTC each half of the year.
So the design splits the two: the availability rule is stored in the creator's local time + IANA zone (Step 3), and each concrete slot is projected to a UTC instant per date, through that zone's DST rules, at the moment it is offered or booked. Booking freezes the instant; browsing renders it into the viewer's own zone at the edge. Store the recurring intent as local time; store the committed fact as UTC — conflating the two is the classic scheduling bug where every session silently shifts an hour each spring.
Step 10 — Trade-offs (each one keeping an NFR)
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
| availability − bookings on the fly | precompute a fixed slot table | windows + buffers change freely; slots are a cheap computation | latency, flexibility |
half-open intervals [a,b) | inclusive ranges | adjacency is unambiguous; back-to-back works, clashes still caught | correctness |
UNIQUE(creator, slot) on the row | trust the app-level overlap check | serializes concurrent racers at the data layer — the true guard | strong consistency |
lazy takeover (expires_at < now) | a cron job sweeping stale holds | no scan, no gap; a pin frees exactly when the slot is next wanted | latency, availability |
| hold → pay → confirm | book first, charge later | a slot is never confirmed without payment; abandoned holds expire | money/slot consistency |
| rule in local time, booking in UTC | store everything as one UTC instant | recurring slots track DST; the booked instant still never drifts | timezone correctness |
The complete implementation
The slot engine is the core. Here's the driver that proves it — slot generation, exact and partial double-book rejection, out-of-window rejection, adjacency, and a second window:
package dev.fiveyear.slots;
import java.util.List;
import dev.fiveyear.slots.SlotBook.Interval;
public class Main {
public static void main(String[] args) {
SlotBook book = new SlotBook();
book.addAvailability(540, 720); // 9:00–12:00
// three 60-minute slots fit the 3-hour window
List<Interval> slots = book.openSlots(60);
assertTrue(slots.size() == 3, "three 60-min slots in a 3-hour window");
assertTrue(slots.get(0).start == 540 && slots.get(2).start == 660, "slots are aligned by duration");
// book the 9:00 slot
assertTrue(book.book(540, 60), "first slot books");
assertTrue(book.bookingCount() == 1, "one booking recorded");
// that slot disappears from the open list
assertTrue(book.openSlots(60).size() == 2, "booked slot no longer offered");
// booking the same slot again is refused (double-booking)
assertTrue(!book.book(540, 60), "exact double-book refused");
// a partial overlap is also refused: 9:30–10:30 clashes with the booked 9:00–10:00
assertTrue(!book.book(570, 60), "overlapping booking refused");
// a non-overlapping slot books fine
assertTrue(book.book(660, 60), "11:00 slot books");
assertTrue(book.bookingCount() == 2, "two bookings now");
// a request that spills past the window end is refused
assertTrue(!book.book(720, 60), "slot outside availability refused");
// ...and one starting before the window
assertTrue(!book.book(480, 60), "slot before the window refused");
// adjacency is allowed: [600,660) touches [540,600) and [660,720) but does not overlap
assertTrue(book.book(600, 60), "adjacent slot (10:00–11:00) books — touching is not overlapping");
assertTrue(book.bookingCount() == 3, "all three hours now booked");
assertTrue(book.openSlots(60).isEmpty(), "no slots remain");
// a second window adds fresh capacity
book.addAvailability(840, 900); // 14:00–15:00
assertTrue(book.openSlots(60).size() == 1, "the new window offers one more slot");
System.out.println("ALL SLOT BOOKING ASSERTIONS PASSED");
}
static void assertTrue(boolean cond, String msg) { if (!cond) throw new AssertionError(msg); }
}Step 11 — Scaling the design, one bottleneck at a time
First, size it, because the numbers change the whole shape. A busy creator publishing three hours of 30-minute slots offers six slots a day; even a million creators produce only a few million bookable slots — trivial write volume. What actually grows is browsing: every visitor loads a creator's slot list, often several before they commit. So this system is read-bound, and the ladder is driven by that read path — never over-built on day one:
- One SQL database. Correct and plenty for a whole marketplace of quiet creators. Reads bite long before writes do — so when the slot-list queries start to hurt…
- Cache the open-slot lists (Redis, short TTL), one key per creator, invalidated on every new booking. Most browse traffic never touches SQL; the DB is left free for the writes that must be correct. When reads outgrow even the cache…
- Add read replicas and fan browse traffic across them while the primary keeps the holds and bookings. When data and write volume finally outgrow one box…
- Shard by
creator_id. Creators are independent, so this is the natural partition key: a creator's availability, holds and bookings all live on one shard, and load spreads across shards for free. Migrating to a different store is the genuine last resort.
The write hot-key is a separate axis. Sharding spreads quiet creators beautifully, but a celebrity mentor dropping ten limited slots to fifty thousand fans is a single hot partition no key can split — the same shape as a blockbuster on-sale in movie-seat booking. Two things save it. Contention is self-limiting: ten slots can produce at most ten winning holds, so you make the losing path cheap — a fast duplicate-key rejection and a readable, cached slot list — because you were never going to let everyone win. And for the true stampede, a metered waiting room admits fans in batches so the hold path is never hit at full force. Cross-link, don't re-derive: the mechanism is identical, only the hot key differs.
Step 12 — When a piece fails: designing for failure
A design is finished when you can say what happens as each box dies. Go component by component, and notice how much the design already handed you for free:
- The slot-list cache dies. Browse falls back to recomputing availability − bookings from SQL — slower, still correct, because the cache was only ever an optimization over the authoritative rows. Browsing degrades; booking is untouched.
- The Redis hold store dies. Holds are ephemeral by design, so nothing durable is lost; new holds fail fast (or fall back to a
slot_holdsrow in SQL), and theUNIQUEon confirmed bookings is the ultimate backstop that still refuses a double-book even with no pin in play. - The SQL primary dies. This is the one failure you can't degrade away — holds and bookings need it — so you keep a replica and fail over, and until failover completes you fail fast, rejecting new bookings cleanly rather than taking money you can't record.
- The payment gateway is slow or down. You can't confirm, so you don't — the hold simply expires on its TTL, the slot reopens, and nobody is charged. Two-phase hold-then-pay turns a payment outage into a non-event.
- The notification / calendar-sync service is down. The booking is already committed (source of truth); reminders and calendar invites are queued and retried, so a missed email never means a lost booking.
- A creator deletes a window with a booking inside. Confirmed bookings are immutable; removing a window stops new bookings only, it never silently cancels a paid session.
The pattern across all six is the lesson: a dependency that's only an optimization (the cache) degrades to the source of truth; a dependency that holds the truth (SQL) gets a replica and a fail-fast guard; a slow external (payments) is arranged so its outage simply expires. Designing for failure isn't preventing every outage — it's deciding, in advance, how the system bends instead of breaks.
The interview corner
Clarify before you draw: How long is a hold (5–10 min is typical)? Is payment in scope (it brings idempotency and reconciliation) or assumed done? Are sessions single-creator only, or can a visitor book a panel of several creators at once (that turns one atomic hold into a cross-row transaction)? What are the consistency and availability targets — what may be stale and cached versus what must hit SQL? Global from day one, or one region (it decides whether you also shard by region)?
The follow-up ladder — each rung a new scenario, not a re-run of the thesis:
- "A creator changes a Tuesday window that already has a 10 a.m. booking on it." The rule and the booking are separate rows: editing
availability_rulesonly changes which future slots are offered; the confirmed booking, with its frozenslot_start_utcand snapshotted price, is immutable. Never let an edit to intent rewrite a committed fact. - "Two creators, one shared visitor — the visitor wants a 30-min buffer between back-to-back sessions with different mentors." Now the overlap test spans two shards keyed by creator; you can't rely on one row's
UNIQUE. Answer: check the visitor's own calendar (keyed by visitor) as a second constraint, or accept that cross-creator conflicts are the visitor's to resolve — and say which, out loud. - "Daylight saving flips this weekend — what happens to a recurring 9 a.m. slot?" Because the rule is stored in local time + zone (not a frozen UTC instant), the projection re-derives 9 a.m. through the new offset automatically; only already-booked slots keep their old UTC instant. Conflating rule and booking here is the bug that shifts everyone an hour.
- "A creator wants group sessions — one slot, up to eight attendees." The slot stops being sold-once and becomes a counter: replace
UNIQUE(creator, slot)with an atomiccapacitydecrement (the reserve-against-a-count pattern), and the hold now claims one of N seats rather than the whole slot. - "The payment succeeded but your confirm write crashed — money taken, no session." An orphaned payment no retry of the same idempotency key can heal, because the booking row was never written. Order the charge second-to-last so a crash just lets the hold lapse, and run a reconciliation sweep that joins recent gateway charges to bookings and completes or refunds the danglers.
Mistakes that fail the round: trusting the app-level overlap check and skipping the UNIQUE constraint (the classic double-book under load); a boolean is_held flag with no expires_at, so a closed browser tab locks a slot forever; and storing a recurring rule as a frozen UTC instant, so every session silently drifts an hour on the daylight-saving weekend.
Where to go from here
- Add group sessions or a panel booking and watch the sold-once slot become a capacity counter, and the single-row hold become a cross-shard transaction — the two hardest follow-ups above, now as builds.
- Zoom the shared mechanisms: the hold-pay-confirm and the atomic claim are the movie-seat booking race one altitude up; the interval and free-busy logic connect to calendar modelling and hotel booking.
- New to system design? The rookie's guide to HLD walks the method this article follows.