R. Raushan
Featuredsystem designmulti-tenancyPostgreSQLStripeconcurrency

How I'd Design a Multi-Tenant Booking Platform From Scratch

A general pattern for multi-tenant booking platforms: pre-materialized slots, pessimistic locking at checkout, Stripe webhooks as the source of truth, and an append-only credit ledger, distilled from a real production system.

May 2, 202615 min

I've built one of these. A multi-tenant platform for booking time with a physical resource, coaches, courts, sessions, across dozens of locations, processing more than a thousand bookings a day with real money moving through Stripe on every one of them. This post is not a retelling of that build. It's the pattern underneath it, the part I'd reuse on the next one regardless of the industry.

Booking platforms look deceptively simple from the outside: pick a slot, pay, get a confirmation. They are not simple, because four hard problems collide on the same write path and none of them tolerate sloppy handling: correctness under concurrency (two people cannot win the same slot), tenant isolation (one location's load or bug cannot corrupt another's data), Stripe's asynchronous payment model (the thing that looks like "payment succeeded" to a browser is not the same event as "payment succeeded" to your ledger), and real-time UX expectations (nobody wants to enter their card details for a slot that was already gone ninety seconds ago).

Most system design writeups treat these as separate concerns. In production they are not. The locking strategy depends on the payment model. The payment model depends on the tenancy model being airtight. The real-time layer exists specifically to paper over the latency the other three introduce. Here's how I'd put it together, and where I'd push back on my own first instincts.

The tenancy model

I'd reach for logical multi-tenancy: one schema, one set of tables, every tenant-scoped row carrying a location_id. Not separate databases per tenant, not separate schemas per tenant.

The case for it is operational, not architectural purity. With one schema, a migration is one migration. A query optimization helps every tenant at once. A new feature ships once. Database-per-tenant buys you hard isolation, but you pay for it on every single schema change, every connection pool, every backup job, multiplied by tenant count. That math works when you have five enterprise tenants who are each paying enough to justify the overhead. It stops working somewhere around dozens of tenants, and it's actively hostile once you're past a hundred.

The trap with logical tenancy is that it only works if every query is scoped consistently. One repository function that forgets the WHERE location_id = ? clause is a cross-tenant data leak, and it will not show up in testing because your test tenant is usually alone in the table. I treat this as a defense-in-depth problem, not a code-review problem:

CREATE INDEX idx_slots_location_time
  ON slots (location_id, start_time)
  WHERE status = 'open';
 
ALTER TABLE slots ENABLE ROW LEVEL SECURITY;
 
CREATE POLICY tenant_isolation ON slots
  USING (location_id = current_setting('app.current_location_id')::uuid);

The index puts location_id as the leading column, because every query in this system is scoped by location first and time range second. The row-level security policy is the backstop: even if application code forgets to scope a query, Postgres refuses to return rows outside the current tenant context. I don't rely on RLS as the only mechanism, application-layer scoping is still the primary path, but I want a second independent layer that fails closed rather than open.

Where I'd push back on logical tenancy: it has a ceiling. If you're running into single-primary write throughput limits, or you land an enterprise customer who contractually needs physical data isolation, the honest answer isn't "rewrite everything as database-per-tenant." It's a hybrid: most tenants stay logical, the handful that need isolation get routed to a dedicated schema or instance. Because your application layer already treats location_id as the seam, that split is a routing decision, not a rewrite.

Slot generation: don't compute availability at query time

The naive design computes availability on read: intersect the resource's availability windows, subtract existing bookings, subtract closures, apply buffer time, and return whatever's left. It looks elegant in a design doc. It falls apart in production because every person browsing a calendar triggers that whole computation, repeatedly, for the same data that hasn't changed since the last person looked at it.

The fix is to materialize availability ahead of time. A background worker rolls a window forward, say the next two to four weeks, and writes concrete slot rows: one row per bookable unit of time, with a status. Reading availability becomes an indexed range scan instead of a live join.

INSERT INTO slots (location_id, resource_id, start_time, end_time, status)
SELECT
  a.location_id,
  a.resource_id,
  gs AS start_time,
  gs + a.slot_duration AS end_time,
  'open'
FROM resource_availability a
CROSS JOIN LATERAL generate_series(
  a.window_start, a.window_end, a.slot_duration
) AS gs
WHERE NOT EXISTS (
  SELECT 1 FROM resource_closures c
  WHERE c.resource_id = a.resource_id
    AND gs BETWEEN c.closed_from AND c.closed_to
)
ON CONFLICT (resource_id, start_time) DO NOTHING;

That query is idempotent on purpose. The generator can run on a schedule, get retried, overlap with itself during a deploy, and the ON CONFLICT DO NOTHING means none of that produces duplicate or corrupted slots. Ad-hoc closures (a court goes down for maintenance, a coach calls in sick) become a write against resource_closures plus a targeted update that flips affected slot rows to closed, not a new rule that every reader has to separately evaluate. Change a resource's recurring availability window and the next generator pass picks it up going forward, no backfill needed because the past is already booked or expired.

Computing at query time feels more "correct" because there's no materialization lag. But that lag is bounded and controlled by your rolling window cadence. Unbounded query latency under concurrent load is not controlled by anything. I'll take the bounded staleness every time.

Pessimistic locking over optimistic, and why

Optimistic concurrency is right for most of this system. Editing a coach's bio, updating a location's hours, adjusting a price: low contention, and the cost of a failed write is a retry. Checkout is the exception, and the reason is the cost asymmetry, not the contention rate. If two people race for the same coveted slot, an optimistic failure means a polite "this slot was just taken." A pessimistic failure in the wrong place would mean charging a card and then telling the customer someone else got the slot. One of those is a UX hiccup. The other is a trust-destroying failure and a chargeback risk. That asymmetry is the entire argument for locking the checkout path pessimistically while leaving everything else optimistic.

I use two barriers, not one:

SET lock:loc_42:court_7:slot_2026-06-30T18:00 "intent_8f3a3e" NX EX 90

A Redis SET NX EX is the fast path. It resolves in low single-digit milliseconds, and a failed acquisition tells the user immediately that the slot is gone, before they've typed a single digit of their card number. The TTL means an abandoned checkout releases the slot automatically, swept by a worker that walks expired locks and reconciles their booking intents back to canceled.

The second barrier is a serializable transaction in Postgres at the moment the booking intent is created: re-check the slot's status, write the intent row, all inside one transaction. This is the layer that actually owns the invariant. Why both? Because Redis is fast, but it isn't your system of record. A failover, an early eviction, two app instances racing during a deploy, any of those can make the Redis lock lie. Postgres can't be wrong about whether a row was already claimed inside a serializable transaction. Redis makes the wrong outcome rare and fast to reject; Postgres makes it impossible.

It's fair to ask why not skip Redis and just hold a SELECT ... FOR UPDATE in Postgres for the duration of checkout. I tried reasoning through that and rejected it: that row lock would have to stay open for however long it takes a human to read a price, get out a credit card, and finish a 3D Secure challenge. Hold that across a dozen concurrent shoppers eyeing the same popular Tuesday slot and you've built a connection-pool exhaustion bug, not a booking system. Redis lets you reject eleven of twelve hopefuls in milliseconds, without ever opening a database transaction for them. Postgres only gets involved for the one shopper who actually has a shot.

Stripe as the source of truth

A client-side redirect after checkout proves the browser navigated somewhere. It does not prove money moved. Tabs close, networks drop, users hit the back button mid-redirect. The only durable signal that a charge succeeded is the webhook Stripe sends you, and that webhook can arrive seconds after the redirect, arrive twice, or in rare cases arrive out of order relative to a refund.

That ordering matters for the sequence of events during checkout. Lock acquisition fires the real-time broadcast that the slot is held, not payment confirmation. Confirmation is too slow to be the UX signal: by the time Stripe's webhook lands, the moment that actually needs to be communicated to every other browser looking at that calendar, "this slot just became unavailable," has already passed. The booking only flips from held to confirmed once the webhook lands, and webhook handling has to be idempotent because Stripe will retry delivery if it doesn't get a timely 200 back from you:

export async function handleStripeWebhook(event: Stripe.Event) {
  const alreadyProcessed = await db.webhookEvents.exists(event.id);
  if (alreadyProcessed) return;
 
  await db.transaction(async (tx) => {
    await tx.webhookEvents.insert({ id: event.id, type: event.type });
    await tx.bookings.confirm(event.data.object.metadata.bookingIntentId);
  });
}

The refund fallback path exists for the genuinely rare case: a lock was held, the TTL expired before payment confirmed, the slot got resold to someone else, and then the original charge lands anyway because the webhook was delayed rather than failed. You can't give that customer the slot back, it's gone. So the system refunds automatically and issues account credit instead of fighting over a slot that no longer exists. That credit lands as a ledger entry, which is the next section.

Real-time availability without a thundering herd

Once you've committed to "the slot disappears from everyone's screen the instant someone locks it," polling is off the table. Enough concurrent clients polling on any interval short enough to feel real-time turns your own availability endpoint into a denial-of-service against yourself.

I'd put a pub/sub layer (GCP Pub/Sub, SNS plus SQS, NATS, whatever your cloud already gives you) between the instances handling locks and the instances holding WebSocket connections. Each instance keeps an ephemeral subscription scoped to the locations its currently-connected clients actually care about, so a lock event for one location doesn't get pushed to every instance in the fleet, only the ones with a socket that's watching it.

flowchart LR
  C1[Client A] -->|watching location 42| WS1[WS Gateway 1]
  C2[Client B] -->|watching location 42| WS2[WS Gateway 2]
  API[Checkout API] -->|lock acquired, loc 42| PUBSUB[(Pub/Sub Topic)]
  PUBSUB --> WS1
  PUBSUB --> WS2
  WS1 --> C1
  WS2 --> C2

Broadcasting on lock acquisition rather than payment confirmation is the same principle as the checkout sequencing above: the UX problem you're solving is phantom availability, a screen showing a slot as open when it's already spoken for. That problem exists in the gap between lock and confirmation, so the broadcast has to happen at the start of that gap, not the end.

The other detail that's easy to skip until it bites you: connection draining on deploy. WebSocket connections aren't request/response, so a rolling deploy that just kills pods mid-connection drops users silently with no error to retry against. The pattern is to stop routing new connections to an instance being retired, push a "please reconnect" message to its existing clients, and let them re-establish against the new revision before the old one terminates.

I'd push back on building this on day one. If you have a handful of locations and modest concurrency, a three-to-five-second poll is genuinely fine, and standing up a pub/sub fan-out layer to solve a thundering-herd problem you don't have yet is just complexity for its own sake. This earns its keep once enough concurrent users are watching the same narrow set of slots that stale reads start producing real, visible double-booking attempts.

Recurrence is harder than it looks

Recurring bookings, the same coach every Tuesday at 6pm for eight weeks, are deceptively hard because the rule and the materialized instances are two different things that drift apart the moment you're not careful.

I store the recurrence rule as data, not as logic embedded in a scheduler:

type RecurrenceRule = {
  id: string;
  locationId: string;
  resourceId: string;
  frequency: "weekly" | "biweekly";
  byWeekday: number[];
  startDate: string;
  end: { type: "count"; count: number } | { type: "until"; date: string };
};
 
type Booking = {
  id: string;
  recurrenceRuleId: string | null;
  scheduledStart: string;
};
 
type SessionRun = {
  id: string;
  bookingId: string;
  actualStart: string;
  status: "completed" | "rescheduled" | "no_show";
};

I'd push back on reaching for the full iCal RRULE grammar here. Unless you're building an actual calendar product, you don't need BYSETPOS or the interaction rules between COUNT and UNTIL. Almost every booking product needs weekly or biweekly, specific weekdays, and an end date or occurrence count. That's the whole vocabulary, and keeping it small keeps the materialization logic small too.

Materialization follows the same rolling-window worker pattern as slot generation: a recurrence engine looks ahead a few weeks and creates concrete booking rows for that window, instead of expanding the rule into every future occurrence at creation time. That matters the first time someone needs to change a rule mid-series. If you'd expanded everything upfront, changing the rule means finding and patching every future row you already wrote. With rolling materialization, you change the rule, and the next pass just generates differently from that point forward.

Canceling one occurrence in an eight-week series shouldn't touch the rule at all, it should mark that one materialized booking as canceled and let the rule keep generating the rest undisturbed.

The entity I'd insist on having separately is SessionRun. The recurrence rule says a session happens every Tuesday at 6pm. Real life disagrees sometimes: this week's session moves to Thursday because the regular slot conflicted with something else. If you only have the rule-generated booking row, you have nowhere to put "this specific instance actually happened at a different wall-clock time" without corrupting what the rule believes is true. SessionRun holds the divergent actual timestamp, attendance, and outcome for that one occurrence, while the parent booking keeps pointing back at the rule it came from. Skip this split and you end up overloading a single row to mean both "what the rule says should happen" and "what actually happened," and the first reschedule breaks your recurrence math.

The append-only credit ledger

A mutable balance column is a trap that looks fine in a demo and turns into a support nightmare in production. The column itself can be updated safely under a database increment. The problem is everything downstream of it: a customer disputes a balance, a refund needs reconciling against a no-show credit issued three months ago, an auditor asks "why is this number what it is," and a single integer has no memory of how it got there.

The pattern is append-only: every change to an account's balance is an insert into a ledger table with a typed amount and a reason, never an update to a running total.

INSERT INTO credit_ledger (account_id, amount, type, reason, reference_id)
VALUES ($1, $2, 'credit', 'no_show_refund', $3);
 
SELECT COALESCE(SUM(
  CASE WHEN type = 'credit' THEN amount ELSE -amount END
), 0) AS balance
FROM credit_ledger
WHERE account_id = $1;

Balance is a derived aggregate, computed on read or materialized into a cache for speed, but the cache is never the write target. Every refund, every no-show credit, every redemption is its own row with its own provenance, which means six months later you can answer "why is this balance what it is" by reading rows instead of trusting a number.

Expiry follows the same philosophy. Promotional or no-show credits often expire after some window, and the instinct is to reach for a TTL. Don't, you need the historical record to persist past expiry. Instead, a credit expiry worker walks unexpired credit rows past their expiry date and inserts an offsetting debit with reason = 'expired'. Expiry becomes another ledger entry, not a deletion, so the books still balance and still explain themselves whenever someone asks.

Background workers: the unsung architecture

By this point there are five workers doing real, independent work: the slot generator rolling the availability window forward, the recurrence engine materializing upcoming occurrences, the TTL lock worker sweeping expired Redis locks and reconciling abandoned booking intents, a progression worker advancing session and attendance state, and the credit expiry worker issuing offsetting debits. Each one owns exactly one piece of state and nothing else.

The thing that has to be true of every single one of them is idempotency. Queue-backed and scheduled work is at-least-once by default: a worker can be retried after a timeout it actually succeeded at, or overlap with itself during a deploy. The slot generator's ON CONFLICT DO NOTHING and the webhook handler's processed-event check earlier in this post are the same defense applied twice, because a handler that isn't idempotent will double-process during some retry eventually, and you'll find out from a confused customer rather than a test suite.

I wouldn't build any of these as cron jobs. Cron assumes a single global execution that finishes before the next tick fires, and that assumption quietly breaks the moment you run more than one instance or hit a slow run. A queue with visibility timeouts and explicit acknowledgment gives you horizontal scale, retry with backoff, and a dead-letter path for the run that keeps failing. Cron gives you none of that, and the failure mode is silent until it's a multi-day backlog.

What I'd do differently

The honest answer, given another shot at this: I'd start with Postgres on day one, full stop. This domain, slots, locks, recurrence, ledgers, multi-tenant joins, financial correctness, is deeply relational and transactional. The flexibility a document store offers early on gets paid back with interest the day you need a serializable transaction across what would be three separate collections, and a document model doesn't hand you that for free.

I'd also revisit lock TTL tuning rather than the locking approach itself. The two-barrier Redis-plus-Postgres pattern earned its place and I'd keep it. But the TTL is a genuine tradeoff with no clean answer: too short, and you release a popular slot out from under a customer who's just slow at typing their card number. Too long, and a crashed tab holds that same slot hostage for the full window while everyone else sees it as taken. I tuned this empirically against real checkout-completion timing data, and I'd do that earlier next time instead of guessing at a round number first.

And I'd go into the project already deciding where the logical tenancy ceiling is, rather than discovering it under load. Logical multi-tenancy is the right default. It is not the right default forever, and knowing in advance which signal (write throughput, a compliance-driven enterprise contract) triggers the hybrid model means you make that call deliberately instead of as an incident response.

Key takeaways

  • Materialize availability ahead of time. Reads should be indexed lookups, not live joins computed on every page view.
  • Pessimistic locking belongs specifically on the checkout write path, because the cost of a failed write there is asymmetric, not because locking is generally safer than optimism.
  • A fast, non-durable lock (Redis) and a durable, correct lock (a serializable transaction) solve different problems. Use both, for different reasons.
  • Stripe's webhook is the only valid signal that payment succeeded. Broadcast availability changes on lock acquisition, not on payment confirmation, or your real-time layer is solving yesterday's problem.
  • An append-only ledger is non-negotiable anywhere money or credit balances move. A mutable balance field is a liability waiting for an audit.
  • Background workers need to be idempotent and queue-backed, not cron jobs with a prayer attached.