Mobile Design Rounds
Build guideAndroid + iOS18 min read

Build it: a booking app on Android and iOS

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: Design a hotel or ride booking app (Android and iOS): holds, payments and recovery · The framework.

This is the implementation material behind the hotel booking interview case. It covers:

  • local tables, REST calls and the booking states
  • the hold clock, idempotency (doing a request twice has the same result as doing it once), wallets and 3-D Secure (an extra check that the bank asks for)
  • search, paging, deep links and the ride contrast
  • platform limits and the iOS version

None of it belongs in the 45 minutes. I draw it only if the interviewer asks. The snippets use Room 2.x (androidx.room), Android's local database library. The official Room guide now shows Room 3 (androidx.room3, KSP only). The tables and the architecture are the same in both versions.

1. Local tables (Room)

  • booking_attempt(attemptId, idempotencyKey, offerId, holdId, status, version): the app writes it before the first network call. It is the record that lets the app recover after the system stops it.
  • trip(bookingId, status, propertyJson, dates, voucherCode, syncedAt): confirmed bookings, which the user can read offline.
  • recent_search(query, placeId, usedAt) and result_page(queryKey, pageIndex, itemsJson, quotedAt): the search side.

The idempotency key (a unique ID that lets the server spot a repeat request) has several names in this guide. They are one value, except attemptId:

WhereNameWhat it is
HTTP headerIdempotency-KeyThe key as the server sees it
Room columnidempotencyKeyThe key, stored before the first send
Kotlin BookingApi.confirmkeyThe same value, passed as a header
Swift PayDelegateattemptKey and idempotencyKeyThe same value, property and parameter
Room columnattemptIdA local row ID, never sent to the server

From here on, "the key" means this idempotency key.

2. REST

GET  /v1/places/suggest?q=lis
GET  /v1/offers?placeId=..&dates=..&cursor=..   → items + nextCursor (opaque), each with quoteId, price, quotedAt
GET  /v1/offers?bbox=..&dates=..                (map viewport)
POST /v1/holds     { offerId, quoteId }     Idempotency-Key → { holdId, expiresInMs, price }
POST /v1/bookings  { holdId, paymentToken } Idempotency-Key → { bookingId, state, version, nextAction? }
GET  /v1/bookings/{id}
GET  /v1/bookings?attempt=<idempotencyKey>      (recovery lookup)

A cursor is a saved position in a list. The server gives the cursor to the client, and the client does not read it ("opaque"). A quote is a price offer with an ID (quoteId) and a time (quotedAt).

The server returns expiresInMs, a duration that it computes when it sends the response. It does not send only an absolute end time. A duration does not depend on the clock of the device.

3. States, quotes, dates and money

Server-driven states:

  • Normal path: HELD, PAYMENT_REQUIRED, ACTION_REQUIRED (only when the bank asks for 3-D Secure), PROCESSING, CONFIRMED.
  • Before CONFIRMED, a booking can end in EXPIRED or FAILED.
  • After CONFIRMED, it can become CANCELLED.

The client copies this list as a sealed type (a Kotlin type with a fixed set of cases). The client never moves the state on its own.

Each response carries a version, a counter for each booking that the server increases. I only compare it. Unlike the seq of the chat app (a sequence number), a booking has no gaps to detect. I ignore any response that is not newer, so late responses cannot move the state backwards.

Dates and money need the same care:

  • A check-in date is a calendar date in the time zone of the hotel. It is not an instant. I send 2026-11-03 as a plain date string. I never send it as a UTC timestamp, because that can move the date by one day in another time zone.
  • Prices travel as a whole number in minor units (the smallest unit of a currency, such as cents) plus a currency code.
  • The server returns the full breakdown: nightly rate, taxes, fees and total. The client formats these numbers and never adds them up.

4. The hold clock

A hold means that the server keeps the room for the user for a short time. The common mistake is to use System.currentTimeMillis(). A user whose clock is 20 minutes fast sees a hold that has already ended. The server decides when the hold ends, and the client only shows it.

  1. POST /holds returns expiresInMs.
  2. Record a monotonic timestamp before you send the request, and compute the time left from it. Network delay then makes the screen show slightly less time than the server grants, never more.
  3. Use a monotonic clock (SystemClock.elapsedRealtime()). It does not jump when the user changes the date.
  4. At zero, lock the pay button, but read the hold again from the server. The server may have extended it.
  5. The server still checks the pay request. On EXPIRED, show "Room no longer held" with an action to check again.
// Sketch: countdown anchored to a monotonic clock
data class HoldClock(val expiresInMs: Long, val sentAtElapsed: Long) {
    fun remainingMs(now: Long = SystemClock.elapsedRealtime()): Long =
        (expiresInMs - (now - sentAtElapsed)).coerceAtLeast(0)
}

5. Idempotency in detail

This is the classic failure. The request reaches the server, the response is lost, and a retry (a second try) or a second tap sends the request again. Without protection, that is two charges.

  • Generate the key once for each checkout attempt (a UUID, a random unique ID). Write it to booking_attempt before the first send. Use the same key on every retry of that attempt.
  • Send it as Idempotency-Key. The docs of Stripe describe this pattern. The client makes a unique key (V4 UUIDs are suggested, up to 255 characters). For API v1, the server saves the first result and returns it on retries.
  • A saved failure is sent again. For API v1, the saved response includes 500 errors. Requests that fail validation, or that collide with a request still running, are not saved. You can retry them.
  • A changed cart needs a new key. Stripe compares the new parameters with the original request. It returns an error if they do not match.
  • A fresh attempt is a new key. Some providers say to start a new key after the user leaves the bank page, after a failed challenge or after a change of payment method. I have not verified that rule on the page of Stripe, so I follow the rules of my provider.
  • Keys expire. Stripe says API v1 keys can be pruned (deleted) once they are at least 24 hours old. Your backend picks its own time window.
  • Double tap: the ViewModel (the class that holds the screen state) ignores Pay while the state is Submitting. The key is the safety net, not the first protection.
  • After a timeout, look before you send again: GET /bookings?attempt=<key>. If a booking exists, use it. If not, send again with the same key.
  • Retry rule (my choice, not Stripe's): retry network errors, 408, 429 and 5xx, and obey Retry-After when it is present. Any other 4xx is final. The exceptions are answers that this API defines as states, such as 409 PRICE_CHANGED and SOLD_OUT. Those go to the user.

This is the same pattern as the send path of the chat app and the second mistake in the offline-first guide. The difference here is that a retry costs money. The app saves the key before the first send. Unlike the chat app, there is no outbox (a table of waiting writes). There is only the one attempt record.

// Sketch: the key comes from the persisted attempt (wrapped by a data source; not called from the ViewModel)
interface BookingApi {
    @POST("v1/bookings")
    suspend fun confirm(
        @Header("Idempotency-Key") key: String,
        @Body body: ConfirmRequest,
    ): BookingDto
}

The pay call runs in a scope that the repository owns (the repository is the class that reads and writes data). It does not run in viewModelScope, because leaving the screen must not cancel a charge in flight. The ViewModel only waits for the result. BookingApi sits behind a remote data source and BookingRepository, so the ViewModel never calls it.

The recovery lookup at launch could be a WorkManager job (Android's scheduler for work that survives restarts). A plain call at startup is enough here.

6. Wallets, 3-D Secure and card data

The wallet sheet produces a token, which is a code that stands for the card. The client never charges anyone. The sheet or the SDK of the payment provider collects the card. My app and my servers only handle a token.

As I understand it, this keeps raw card numbers out of my systems and makes PCI scope smaller (PCI is the card industry's security rules). I have not verified the exact scope rules, so confirm them with your payment provider.

  • Google Pay: the user picks a method in the sheet. Google Pay returns a token to the app, which sends it with the purchase details to your backend. The backend passes it to the payment provider. PaymentsClient.isReadyToPay decides whether to show the button, and loadPaymentData opens the sheet.
  • Apple Pay: PKPaymentAuthorizationController shows the sheet. In didAuthorizePayment, you send the token to your server and report the result.
  • 3-D Secure: in the flow of Stripe, the PaymentIntent (the record of one payment) moves to requires_action. The SDK shows the challenge. Or a web view uses a return_url that routes back into the app. Stripe says 3-D Secure is not supported for every transaction, and wallets are one example. In practice, you meet it with cards.

The result of the redirect is a hint, not the truth. The user may finish the bank page and never return. Or the user may return to a new process (a new running copy of the app). On resume, or on the return link, move to Processing and ask the server for the booking state.

  • Poll (ask the server again and again) with backoff (wait longer after each failure), or wait for a push. A push is only a hint, so I still poll once.
  • The server learns the real result from provider events. Stripe says the 3-D Secure result typically arrives in a charge.succeeded event. The server shows it as the booking state.
  • In its web flow, the page of Stripe says to get the updated PaymentIntent after the challenge. This is the same idea: ask, and do not assume.

Polling budget, as arithmetic on an assumption: delays of 1, 2, 4 and then 8 seconds (the longest delay) give 9 polls in 55 seconds. After about a minute, I show "still checking" and rely on push and the next launch.

7. Process death mid-payment

Process death means that the system stops the running copy of the app. SavedStateHandle survives when the system stops the app. It does not survive a force stop by the user or a swipe away from Recents, and it is meant for small values. The page of Android says saved state is tied to the task stack.

Keep only the attemptId there, and keep the rest in Room. At startup, find the booking_attempt rows that are not in a final state. Reconcile each one with GET /bookings?attempt=<key>. To reconcile means to compare the phone with the server and fix differences. After a force stop, the Room row still lets this run at the next launch. Only the screen that was open is lost.

8. Search and autocomplete

Debounce (wait a short time after the last key press, for example 250 ms, tuned with data), trim, drop duplicates, and cancel the previous request. Then a slow "lis" cannot replace "lisbon".

  • Show recent searches from Room when the field is short, and cache suggestion responses by query.
  • Short input skips the debounce, so the recents appear at once.
  • If the suggest call fails with an IOException (for example when offline), the list becomes empty. Without this, the error would reach viewModelScope and crash the app on a key press.
  • Other exceptions still go up, because I would rather see a real bug than hide it.

The snippet needs import java.io.IOException, which the block does not show.

// Sketch: debounce + cancel-previous with flatMapLatest
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(private val repo: PlaceRepository) : ViewModel() {
    private val query = MutableStateFlow("")

    val suggestions: StateFlow<List<Place>> = query
        .debounce { if (it.trim().length < 2) 0L else 250L }   // recents show at once
        .map { it.trim() }
        .distinctUntilChanged()
        .flatMapLatest { q ->
            if (q.length < 2) repo.recents()        // Flow<List<Place>> from Room
            else flow { emit(repo.suggest(q)) }     // cancelled by the next query
                .catch { if (it is IOException) emit(emptyList()) else throw it }
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    fun onQueryChanged(q: String) { query.value = q }
}

9. List, map, staleness and offline

List and map. Use Paging 3 with a RemoteMediator. The news feed case explains it. The database is the source of truth. The mediator is the signal that the cached data ran out, so it fetches the next page into Room.

  • Put the whole query (place, dates, guests, filters) in the page key, so a change of filter starts a new list.
  • Load the next page before the user reaches the end (PagingConfig.prefetchDistance).
  • For the map, query by viewport (the visible part of the map) once the camera stops. Debounce, cancel the previous request for a map box, and group nearby pins on the server.

Staleness. Treat every price as a quote. Items carry quoteId and quotedAt.

  • Show "Prices may have changed" on old cached pages.
  • Refresh when the user pulls down, and when the app returns to the foreground after a long gap.
  • Always check the price again at checkout. POST /holds takes the quoteId. If the price differs, the server answers 409 PRICE_CHANGED with the new price, and the user accepts it or goes back.
  • SOLD_OUT returns the user to the results, with the item marked. The client never computes the final total.

Offline. Confirmed bookings sit in trip with the confirmation code and the address, so the user can show them at the door. Search shows cached pages with a banner that says the data is old. Hold and pay are disabled, and the screen shows the reason.

A deep link is a link that opens a screen inside the app. A confirmation email opens https://example.com/trips/<id>.

  • Android: use verified App Links. That means an intent filter with autoVerify, plus assetlinks.json on your domain. On Android 15 and later, the system checks domains again from time to time. Allow up to a week for changes to that file to reach devices.
  • iOS: use universal links. The guide of Apple calls them a possible attack path and says to discard malformed URLs.
  • Both: check every parameter. Route by ID. If the user is signed out, sign in first. Show the cached trip, then refresh from the server. Never trust state that is encoded in the link.

11. Ride hailing: what changes

I treat this as a contrast, not as a second design. The base is the same: state owned by the server, idempotent requests and a state machine. The scarce thing changes. In a hotel it is rooms. In a ride it is drivers who are moving. This is industry practice, not a documented source.

AspectHotelRide
ReservedA room for dates, held for minutesA driver, matched in real time
TimeoutHold expiryMatching timeout; show "no drivers", offer retry
PriceQuote, checked again at checkoutEstimate shown before the request
Live dataAlmost noneDriver location stream
PaymentAt bookingOften authorized up front, finalized after the trip
OfflineSaved bookings are usefulLittle value

For live location, open a WebSocket (a two-way open connection; OkHttp on Android, or URLSessionWebSocketTask on iOS) only while the trip screen is visible. Move the marker smoothly between points. On reconnect, fetch the latest trip state by ID instead of replaying the stream. The ride request is idempotent, like a booking. After the app is stopped, the client resumes by asking for the active trip.

12. Platform and store limits

LimitAndroidiOSSo the design changes by
App stopped while the user is in the bank page or wallet sheetSavedStateHandle survives when the system stops the app, not force stop or a Recents swipe, and holds small values onlyNot checked hereSave the attempt row; restore it at launch on both
Work after the screen is goneCoroutines stop when the app closes; WorkManager persists across restarts and rebootsNot checked hereThe pay call runs in a repository scope; the lookup at startup repairs what the scope lost
Wallet resultGoogle Pay returns a token; how the result reaches the activity is not on the pages I readThe sheet waits for your handler; the delegate is main-actorKeep the server call short; an unknown outcome fails the sheet and starts a lookup
RolloutA halted Play rollout reaches no more users; users who have the build keep itPhased release runs seven days; it can be paused 30 days in total; manual updates always workOld versions stay in the field; the server keeps accepting the old shape
Deep link checksautoVerify and assetlinks.json; Android 15 checks again from time to timeUniversal links; discard malformed URLsCheck every parameter on both
Payment rules in the storeGoogle's matching policy not checkedApp Review 3.1.3(e) says goods and services consumed outside the app use something other than in-app purchase; hotels and rides are not namedPay by wallet or card token, not store billing; read the rule again before shipping

13. How to build a booking app on iOS (Swift)

The architecture carries over. What changes is the platform tools.

ConcernAndroidiOS
Local DBRoomSwiftData or Core Data (or GRDB)
WalletGoogle Pay API (PaymentsClient)PKPaymentAuthorizationController
3-D Secure returnPSP SDK; return_url via App LinkPSP SDK; return_url via universal link or custom scheme
Process deathSavedStateHandle (ids) + RoomPersisted attempt record, restored on launch
Deep linksApp LinksUniversal links (associated domains, NSUserActivity)
Monotonic clockSystemClock.elapsedRealtime()ContinuousClock (counts through sleep; its behaviour when the date changes is not on the Apple page, so treat that as unverified)
Cancel-previous searchflatMapLatestCancel the previous Task
PagingPaging 3 + RemoteMediatorCursor loading; no official paging library
Live locationOkHttp WebSocketURLSessionWebSocketTask

PSP means payment service provider, the company that takes the payment.

What changes in practice:

  • No durable retry framework is needed. Booking works online only on both platforms. There is no outbox of waiting writes. There is only the saved attempt record and the check against the server at launch.
  • Scene phase. SwiftUI's ScenePhase reports active, inactive and background. When the app returns to active after the bank page, fetch the booking state. Close the driver socket when the app leaves the foreground.
  • The Apple Pay result blocks the flow. Apple documents that the controller waits for your handler before the next delegate method. In the async variant used below, it waits for the method to return. Apple declares the delegate @MainActor, so the sketch does too.

Keep the server call short. The sketch sets no timeout, so add a short one on confirm. Then the three outcomes are:

  • A definite error from the server fails the sheet.
  • A URLError, such as a timeout, means I do not know whether the card was charged. I still fail the sheet, because I will not show a success mark for a payment that I have not confirmed.
  • The delegate also tells the screen to move to PROCESSING, and the screen looks the booking up by the attempt key. If the card was charged, the lookup finds the booking. If not, a retry uses the same key, so the server cannot charge twice.

The other option is to report success and reconcile later. That looks smoother, but it is wrong whenever the charge failed.

// Sketch: Apple Pay delegate sending the wallet token with the persisted attempt key
@MainActor
final class PayDelegate: NSObject, PKPaymentAuthorizationControllerDelegate {
    let api: BookingAPI
    let attemptKey: String
    let onUnknownOutcome: () -> Void   // the screen moves to Processing and looks the booking up by key
    init(api: BookingAPI, attemptKey: String, onUnknownOutcome: @escaping () -> Void) {
        self.api = api; self.attemptKey = attemptKey; self.onUnknownOutcome = onUnknownOutcome
    }

    func paymentAuthorizationController(
        _ controller: PKPaymentAuthorizationController,
        didAuthorizePayment payment: PKPayment
    ) async -> PKPaymentAuthorizationResult {
        let token = payment.token.paymentData
        do {
            _ = try await api.confirm(idempotencyKey: attemptKey, walletToken: token)
            return PKPaymentAuthorizationResult(status: .success, errors: nil)
        } catch is URLError {
            // Timeout or dropped connection: the server may already have charged.
            // Fail the sheet, then look the booking up by the attempt key (a retry reuses it).
            onUnknownOutcome()
            return PKPaymentAuthorizationResult(status: .failure, errors: nil)
        } catch {
            return PKPaymentAuthorizationResult(status: .failure, errors: nil)
        }
    }

    func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) {
        controller.dismiss()
    }
}

14. Edge cases checklist

ScenarioHandling
Device clock wrongServer duration plus monotonic clock; server enforces expiry
Double tap on PayViewModel ignores while Submitting; idempotency key collapses duplicates
Response lost after booking createdLook up by attempt key; use the server state; keep the same key
Hold expires during 3-D SecureServer returns EXPIRED or voids per its rules; client shows server state, offers a re-check
App stopped in the bank appbooking_attempt in Room; check against the server at startup
User returns without finishingTreat as unknown; fetch state; offer resume or cancel
Price changed since search409 with new price; user confirms
Last room soldSOLD_OUT at hold; back to results
Late response for old searchCancelled by flatMapLatest; keyed by query
Out-of-order updatesCompare version; ignore older

15. More practice prompts

These are practice prompts, not a claim about what any interviewer asks. They are my reconstruction.

  1. How do you support modify and cancel? Use the same idempotent pattern with a version check first. The refund state comes from the server.
  2. How do you handle two devices on one account? Server state is the truth. Trips sync by version. A hold belongs to the user.
  3. How do you test checkout? Use a fake API that drops the response after success, forces PRICE_CHANGED and EXPIRED, and sends versions out of order. Stop the process in the middle of an attempt.
  4. What happens after the booking? Cancel and modify with refund state, push updates and a calendar entry. The ride contrast adds matching, a cancellation fee and surge (higher prices when demand is high).

Further reading (official docs)

Verification notes (last reviewed 13 Oct 2026)

this is the build guide for the booking case. The implementation material moved here from the old single page on 2026-10-14, and the interview part is a separate page. The code blocks (REST sketch, HoldClock, BookingApi, SearchViewModel, PayDelegate) are byte-identical to the audited page and were diffed after the split. The prose around them was reflowed for readability and the sections renumbered. Everything marked Verified, Unverified or compiled in the earlier notes still applies, and is repeated here. Verified against fetched docs on 2026-10-13: Stripe idempotency and 3DS (re-read again on 2026-10-14: V4 UUID suggestion, 255 character limit, API v1 saves the first result including 500 errors, keys can be pruned once at least 24 hours old, parameters are compared, validation failures and concurrent conflicts are not saved, wallets are listed as not always supporting 3DS, charge.succeeded is named as the event that carries the authentication outcome, and the page says to retrieve the updated PaymentIntent after the challenge); Google Pay token flow, isReadyToPay, loadPaymentData; Android offline-first write strategies (re-read 2026-10-14: online-only writes fit work that must happen online in near real time, with a bank transfer as the example, and the page suggests not offering or disabling the write when offline), RemoteMediator behaviour, SavedStateHandle (re-read 2026-10-14: survives system-initiated process death, lost on force stop or removal from Recents, state must be simple and lightweight, no numeric limit given); App Links autoVerify, assetlinks and the Android 15 periodic re-verification with up to seven days to propagate; Apple PKPaymentAuthorizationController and its delegate, including that both didAuthorizePayment variants are @MainActor, universal link handling and security warning, ScenePhase, URLSessionWebSocketTask. Play Console staged rollout help and the App Store Connect phased release page were re-read on 2026-10-14: a halt stops further installs and installed users keep the version; the Apple page gives seven days, a 30 day total pause and manual updates at any time, and is silent about users who already have the update. The persistent-work page was re-read on 2026-10-14 (WorkManager work persists across restarts and reboots; coroutines stop when the app closes). SystemClock.elapsedRealtime ("milliseconds since boot, including time spent in sleep") was confirmed in the Android audit pass. The SystemClock and ContinuousClock reference pages did not return their body text on 2026-10-14, so those two clock claims stand on the earlier audit, and the ContinuousClock date-change behaviour is unverified. Unverified: the 10 minute hold, the 250 ms debounce and the polling delays are illustrative; PagingConfig.prefetchDistance is from general knowledge; how Google Pay returns its result to the activity; Apple Pay sheet time limits; the "new key for a fresh attempt" convention some providers state (taken from a coverage note, not re-read on Stripe's page); the PCI scope paragraph is my understanding, not a checked source; App Review 3.1.3(e) was read through a tool summary only and Google's matching policy was not checked; the ride-hailing table (pre-authorization, matching) is industry practice, not a documented source. The polling budget is arithmetic on an assumption: 1 + 2 + 4 = 7 s, then six polls at 8 s is 48 s, so 9 polls in 55 s. BookingAPI.confirm, ConfirmRequest, BookingDto and PlaceRepository are placeholders. Code checks, as before: the HoldClock, BookingApi and SearchViewModel snippets were compiled offline with Gradle 9.8.1, Kotlin 2.3.21, AGP 9.1.0, compileSdk 36, kotlinx.coroutines 1.10.2 and lifecycle 2.10.0 (compileDebugKotlin clean). Stubbed: Retrofit (not in the offline cache, so the BookingApi annotations are stubs, and Retrofit was not run), and the app types ConfirmRequest, BookingDto, Place and PlaceRepository; Hilt is not used in these snippets. The search pipeline (instant recents, IOException giving an empty list) was also run in a small JVM probe on coroutines 1.10.2 outside the ViewModel, not on a device. Swift: the PayDelegate was type-checked with Swift 6.4 against the iOS 27.0 simulator SDK, language modes 5 and 6, both clean, with a stub for BookingAPI. Not run: the Apple Pay flow itself, so what the sheet does when the handler returns .success for an unknown outcome is from reading the docs, not from a device, and the timeout is not in the sketch. Newer releases of these libraries exist and the check was not re-run against them. No iOS engineer has reviewed the iOS sections yet. Second review pass on 2026-10-14: code blocks diffed byte-identical to the audited copy; the Room 3 note was re-checked on the official Room page (Room 3 artifacts, KSP only); the Stripe, Android App Links (up to seven days on Android 15 and higher), Play and App Store Connect pages were re-read and every external link returned 200. The charge.succeeded sentence now says the 3-D Secure result typically arrives in that event, and the PaymentIntent retrieval is scoped to Stripe's web flow. The retry rule is labelled my choice. The Apple Pay and universal link pages returned no body text, so those claims rest on the earlier audit. A plain-English pass on 2026-10-14 used simpler words and defined each technical term at first use. It changed no fact, number, link, code block or marker.

Questions or corrections?

Add them to the discussion thread for this case on GitHub. To post, you need a free GitHub account. What you write there is public and follows GitHub's terms. Good answers are added to this page.

Keep reading

Get the next case study when it is published.

You get an email when a new case study is published, and nothing else.

You can unsubscribe at any time. Privacy.