Space Tourism
A seat-booking platform whose central constraint is that a launch must never sell past capacity — including under a rush of simultaneous buyers.
- stack
- React · TypeScript · Express · MongoDB · Redis · Stripe · Docker
Booking is the whole problem. Everything else — the catalog, the admin panel, the emails — is ordinary CRUD wrapped around one operation that has to be correct when several people click Book on the last seat at the same moment.
Reserving seats without a transaction
The seat count is decremented and guarded in one MongoDB operation, with the capacity check living in the query filter rather than in application code:
Launch.findOneAndUpdate(
{ _id, seatsAvailable: { $gte: seats }, status: "scheduled" },
{ $inc: { seatsAvailable: -seats } },
{ new: true },
);
There is no read-then-write window to lose. If the filter doesn’t match — because
another request got there first and drove seatsAvailable below what this one
needs — no document is returned, no booking is created, and the request gets a
409. Correctness comes from the database’s per-document atomicity, so it holds
without a multi-document transaction or an application-level lock.
npm run loadtest fires 50 concurrent bookings at a launch with one seat.
Exactly one wins; the other 49 get 409. It runs against a real Mongo instance,
so it is a test of the actual guarantee rather than of a mock.
Payment as a state machine
Stripe hosts the card form, so the app never sees card data. That means the browser can’t be trusted to report success, and the flow is built around that:
POST /api/bookingsreserves seats atomically, writes apendingbooking with anexpiresAthold, and returns a Checkout URL.checkout.session.completedarrives at the webhook — signature-verified, and idempotent, so a replayed delivery confirms the booking once.checkout.session.expiredreleases the held seats back to inventory.- A background sweeper independently reclaims any
pendingbooking past its hold, covering the case where the webhook never arrives at all. - Cancelling refunds through Stripe, restores the seats, and invalidates the cached launch.
Steps 3 and 4 overlap deliberately. The webhook is the fast path; the sweeper is the one that makes the invariant hold when the fast path fails.
The rest of the backend
Money is stored end to end as integer USD cents — floats never touch a price.
zod validates request bodies and the environment at boot, so a missing secret
fails at startup rather than at the first request that needs it. Redis carries
sessions (connect-redis), a cache-aside layer over the read-heavy catalog
endpoints, and rate-limiter-flexible token buckets. Admin routes are gated on a
role field checked server-side — the hidden UI is a convenience, not the
control.