Design the client of a flight booking app: search, seat hold, payment and confirmation. How do you handle a timed hold, double taps and a payment that fails?

26.8.3 · L5 · Interview

Reported at: Meta

Predict first

What state does the machine reach for each event, and what happens to a hold that expires?

import Foundation

enum Booking: Equatable {
    case searching
    case held(seat: String, expires: Int)
    case paying(seat: String, key: String)
    case confirmed(seat: String)
    case expired
    case failed(String)
}
enum Event { case hold(seat: String, now: Int), startPayment(key: String), paid, paymentFailed(String), tick(now: Int) }

func reduce(_ s: Booking, _ e: Event) -> Booking {
    switch (s, e) {
    case (.searching, .hold(let seat, let now)): return .held(seat: seat, expires: now + 600)   // server holds the seat for 10 minutes
    case (.held(let seat, _), .startPayment(let key)): return .paying(seat: seat, key: key)
    case (.held(_, let expires), .tick(let now)) where now >= expires: return .expired
    case (.paying(let seat, _), .paid): return .confirmed(seat: seat)
    case (.paying(let seat, _), .paymentFailed(let why)): return .failed("\(why) (seat \(seat) released)")
    default: return s                                  // an event that makes no sense in this state is ignored
    }
}
var s = Booking.searching
let script: [Event] = [.hold(seat: "12A", now: 0), .tick(now: 300), .startPayment(key: "k-1"), .startPayment(key: "k-2"), .paid, .paid]
for e in script { s = reduce(s, e); print(s) }
var late = reduce(.searching, .hold(seat: "3C", now: 0))
late = reduce(late, .tick(now: 601)); print(late)
late = reduce(late, .startPayment(key: "k-3")); print(late)
  • held / held / paying k-1 / paying k-1 / confirmed / confirmed, then expired / expired
  • held / expired / paying k-2 / confirmed / confirmed / confirmed
  • held / held / paying k-1 / paying k-2 / confirmed / paying

Mental model. A theatre box office that puts your name on a seat for ten minutes. Pay within the time and it is yours; ask twice and the clerk notices you already asked; take too long and the seat goes back on sale.

Reported at Meta (anonymous, unverified). Model the booking as an explicit state machine instead of a set of flags: searching, held (seat and an expiry time), paying, confirmed, expired and failed. A pure reduce(state, event) function makes every rule testable and makes illegal moves impossible: an event that does not fit the current state is ignored, so a second startPayment while already paying, or a payment after the hold expired, changes nothing. The check walks a script through it: hold at time 0 (expires at 600), a tick at 300 keeps it, payment starts, a second payment start with another key is ignored, paid confirms, a late paid is ignored; a hold that sees a tick at 601 expires, and a later payment attempt is refused.

The server owns the truth for the seat: the client asks for a hold and receives an expiry, shows a countdown computed from the server’s time (not the phone’s clock, which can be wrong), and treats the hold as lost when the server says so. Use an idempotency key per payment attempt so a double tap or a retry after a timeout does not charge twice (Q493), and on an ambiguous result (timeout after sending), ask the server for the booking status rather than guessing. Offline, searching can show cached results, but holding and paying need the network, so say that plainly. Other points to name: price changes between search and hold (show the new price and ask), accessibility of the countdown, and restoring the flow after the app is killed by asking the server for the open hold. Only the state logic is run here; payments and the server are not.

Events that do not fit the current state are ignored, the first payment key wins, and a hold past its expiry cannot be paid.

Common traps

  • Counting down the hold from the phone’s clock. Use the server’s expiry and its time.
  • Charging again on a retry. Without an idempotency key, a timeout followed by a retry can double charge.

They might follow up with

  • What if the user pays at the last second? Let the server decide: it accepts the payment only if the hold is still valid, and the client shows whatever it returns.
  • How do you survive the app being killed mid-payment? On launch, ask the server for the user’s open booking and hold, and resume from that state.

Related questions

  • 26.1.4 Design a shopping app with cart and checkout: catalogue, cart merging, price authority, payment, idempotency…
  • 26.2.1 How do you design a client for reliability: deadlines, circuit breakers, degradation tiers, bulkheads, kill…
  • 26.8.1 Design an API rate limiter, then say how an iOS client should behave under one. Compare token bucket with a…

← How does the client learn about new data: polling, long polling…   Design the client of a Tinder-style swipe app: card prefetching… →