How to read this. This page is the 45 minutes, in the order I would spend them. It uses a checkout flow as the example, but the same shape fits upload, sign-in, onboarding and video playback. The follow-up questions are my reconstruction from public prep material, not transcripts of real rounds.
Build it. The Kotlin and Swift code, the saved attempt record and the tests are on a separate page: Build it: a state machine for a mobile flow on Android and iOS (20 min read).
By the end you can: draw the states and events of a flow on a whiteboard in five minutes; explain why a disabled button is not a guard; and say what the app does when it is killed in the middle of a payment. Read section 4.2 twice. It is the part most answers miss.
TL;DR. My answer: one state machine per flow, with a short list of states and events and one pure function that maps a state and an event to the next state. The screen only draws the state. Network calls and saves are effects that the function asks for, not things it does. Before a payment goes out, the attempt id is saved. If the app dies or the answer is lost, the flow starts in an Unknown state and asks the server what happened.
Patterns in this round. State machine (the checkout is a closed list of states and events); Observer (the screen draws the state and sends events); Memento (the attempt id is saved before the payment leaves); Idempotency key (the attempt id is the key). More in design patterns in mobile rounds.
1. Scope and constraints (minutes 0 to 4)
In short: Design the state of one flow and its recovery. Server payment logic and the visual design wait.
A prompt I would expect: "Design the checkout flow in our shopping app: cart review, address, payment, confirmation. We have bugs: double charges, a spinner that never ends, and the wrong screen after the user rotates the phone or comes back later." I ask five questions, with a default for each:
- One flow or many? One flow, four steps. The method has to scale to more.
- Who charges the card? A payment provider and our server. I design the phone and the contract.
- Can the user leave mid-flow? Yes, at any step, and the system may kill the app.
- Is the network reliable? No. Calls can fail, time out, or succeed with a lost answer.
- Do we need to resume after a restart? Yes, but only the part that costs money.
| In scope | Out of scope | Left out until you ask |
|---|---|---|
| States, events and transitions of the flow | The payment provider SDK screens | Several flows that talk to each other |
| Double taps, late answers, rotation, process death | The server order pipeline | Time travel debugging tools |
| Recovery when the answer to a payment is lost | Refunds and disputes | Analytics on each transition |
The phone does four things to this design:
| What the phone does | Requirement at risk | Deep dive |
|---|---|---|
| Rotates the screen and recreates the screen object | The user keeps their place and input | Section 3 |
| Kills the app process while it is in the background | No lost or repeated payment | Section 4.2 |
| Delivers a tap twice, or two taps close together | One payment per intent | Section 4.1 |
| Loses the signal after a request left | The app knows if it was charged | Sections 2 and 4.2 |
Your decision. The scope grows: "Also add Apple Pay and Google Pay, saved cards, and coupon codes." Which do you keep?
First idea, and why it fails. Say yes and add a state for each: ApplePaySheet, SavedCardSelected, CouponInvalid. The list doubles, and nobody can say which combinations are possible. Time runs out before the lost-answer path.
What I would say.
Say this. "The wallets, cards and coupons are inputs to the same flow, not new flows. I model the flow first with one Pay step. Each method then fills in the payment details and does not add states. Tell me if you want one earlier."
The follow-up question I expect: "Why not just a boolean isLoading and a nullable error?"
It returns in section 3.
45-minute plan
| Minutes | Step | Drawn by the end | Cut if behind |
|---|---|---|---|
| 0 to 4 | Scope and constraints | Scope list, phone behaviors | Fewer questions |
| 4 to 14 | Data and contracts | State list, event list, transition table | Effects detail |
| 14 to 22 | Client structure | Reducer, store, effect runner, screen | One line per layer |
| 22 to 40 | Make the hard part work | Double taps, the Unknown state, late answers | iOS notes |
| 40 to 45 | Break it, ship it, roll it back | Failures, tests, release rule | Two failures |
2. Data and contracts (minutes 4 to 14)
In short: A few states, a few events, a table that says what happens for every pair. The saved part is only what costs money.
Your decision. How do you describe where the checkout is? Flags such as isLoading, isPaid and error, or one value that says which state the flow is in?
First idea, and why it fails. Three or four flags on the screen state. Two problems:
- With three booleans there are eight combinations, and only a few of them are real. isLoading with isPaid is a bug, but the type allows it.
- Every handler has to check the flags in the right order. One missing check shows a Pay button during a payment.
What I would say.
Say this. "One state type with a closed list of cases. Each case holds only the data that makes sense there, so impossible mixes cannot be written down. Events are a closed list too. Every state and event pair has an answer, even if the answer is to do nothing."
The states for this flow:
| State | Holds | Screen shows |
|---|---|---|
| Editing | The form data and any field errors | The form, Pay on |
| Submitting | The attempt id and the form data | A spinner, Pay off |
| Unknown | The attempt id | "Checking your payment", no Pay button |
| Succeeded | The order id | The confirmation |
| Failed | A reason and whether retry is allowed | The error, and Edit or Retry |
The events: FieldChanged, PayTapped, PayAnswered (paid or declined), PayTimedOut, AppRestored, StatusChecked, RetryTapped. The transitions that matter:
| From | Event | To | Effect asked for |
|---|---|---|---|
| Editing | PayTapped (form valid) | Submitting | Save the attempt id, then send the payment |
| Submitting | PayTapped | Submitting (no change) | None. This is the double tap. |
| Submitting | PayAnswered: paid | Succeeded | Clear the saved attempt |
| Submitting | PayAnswered: declined | Failed (retry allowed) | Clear the saved attempt |
| Submitting | PayTimedOut | Unknown | Ask the server about the attempt id |
| Unknown | PayAnswered (late, same id) | Succeeded or Failed | Clear the saved attempt |
| Unknown | StatusChecked: paid | Succeeded | Clear the saved attempt |
| Unknown | StatusChecked: not paid | Editing | Clear the saved attempt |
| Unknown | StatusChecked: still pending | Unknown | Check again later with a delay |
| Failed | RetryTapped | Editing | None. A new attempt gets a new id. |
Two details carry the safety. First, the attempt id is made and saved before the request leaves. It is also the idempotency key (a key that makes a repeated request count once) that the server sees (Stripe idempotent requests). Second, the only saved state is the attempt id and which state it was in. Editing, Succeeded and Failed can be rebuilt from the cart and the server. If the user changes the cart after a failure, the next attempt needs a new id: Stripe rejects the same key sent with different parameters (Stripe idempotent requests).
The contract needs two calls: POST /orders with the attempt id in a header, and GET /orders?attempt=ID that answers paid, not paid or pending.
The follow-up question I expect: "The payment SDK has its own screen and its own result callback. Where does that go?"
Into the same flow. The callback becomes the PayAnswered event. The SDK stays an effect, and the state machine does not know about it.
3. Client structure (minutes 14 to 22)
In short: One function decides. One owner holds the state. The screen draws it. Effects run outside.
Your decision. Where does the decision logic live? In the screen, in the ViewModel (the class that holds a screen's state), or in a pure function that nothing else can touch?
First idea, and why it fails. Handlers in the screen that set flags and call the network directly. Three problems:
- The logic cannot be tested without a screen.
- A rotation recreates the screen, and a handler that was running now writes to a screen that is gone.
- Two callbacks can change the state from two threads, and the order is luck.
What I would say.
Say this. "A pure function takes the current state and an event and returns the next state and a list of effects. One owner, the ViewModel on Android and a main-actor object on iOS, holds the state and feeds events in one at a time. The screen only sends events and draws the state. An effect runner does the network call and the save, and sends its result back as an event."
Because every change goes through one queue, the order of two events is the order they arrived, and the test for the whole flow is a list of events and the states you expect. A rotation changes nothing: the ViewModel outlives the screen (Android ViewModel).
The follow-up question I expect: "Why not a full library like a statechart or a big redux framework?"
For one flow, about fifty lines of plain code are enough, and an interview answer should show the idea, not a dependency. I would name the trade: a library adds tooling and diagrams, and costs a learning curve for the team.
4. Make the hard part work (minutes 22 to 40)
In short: Ignore what the state cannot use, never pay twice, and treat a lost answer as its own state.
4.1 Double taps and events that do not fit (minutes 22 to 30)
Your decision. The user taps Pay twice in 200 ms. What happens? Disable the button, throw an error, or something else?
First idea, and why it fails. Disable the button after the first tap. The button is the only guard. A second tap that was already queued, a Pay from a keyboard shortcut, or a retry after the screen is recreated still arrives. Only the state knows if a payment is under way.
What I would say.
Say this. "The button is a hint to the user. The guard is the transition table. In Submitting, PayTapped returns the same state and no effects, so a second tap does nothing, even if it arrives. An event with no row in the table is dropped and logged, not thrown, because a late event is normal on a phone."
The same rule covers a rotation in the middle of a call: the state stays Submitting, and the answer arrives into it.
4.2 Process death and the lost answer (minutes 30 to 38)
Your decision. The user taps Pay, the request leaves, and the system ends the app process. The user comes back. What does the app show, and what must it never do?
First idea, and why it fails. Start again from Editing, since no state was saved. The user taps Pay a second time, and the first charge went through. That is a double charge. Android says the system may destroy the app process while the user is away, and then the ViewModel is gone too (Android saving UI state).
What I would say.
Say this. "Before the request leaves, I save the attempt id and the word Submitting. After a restart, the flow sees that saved attempt and starts in Unknown, not Editing. From Unknown it only asks the server what happened to that id. It shows paid, or not paid, and offers a new attempt with a new id. It never sends the old request again without the key."
Where the record lives matters. On Android, SavedStateHandle keeps small values across a system-initiated process end. But the same page says it is lost when the user swipes the app away, force-stops it or reboots, and it is not long-term storage (ViewModel saved state). A payment attempt must survive all of those, so I write it to DataStore or Room before the request leaves. On iOS the system makes no guarantee when SceneStorage is written, wants lightweight data, and deletes it when the scene is destroyed (Apple SceneStorage). So the attempt record goes to a file or database, and SceneStorage is for restoring the screen only.
Replay the same story as a table. Each row is one event, and each state follows from the transition table in section 2.
| # | Event | State after | Saved record | Effect |
|---|---|---|---|---|
| 1 | PayTapped | Submitting (A1) | A1, Submitting | Save A1, then POST with key A1 |
| 2 | PayTapped again | Submitting (A1) | A1, Submitting | None: double tap ignored |
| 3 | App killed | (gone) | A1, Submitting | None: the process is dead |
| 4 | AppRestored | Unknown (A1) | A1, Submitting | GET status of A1 |
| 5 | StatusChecked: paid | Succeeded | Cleared | Show the order |
The follow-up question I expect: "The status check says pending for a minute. What does the user see?"
The Unknown screen: "Checking your payment". The check repeats with a growing delay and a cap. After the cap, the screen says: "We could not confirm. You will get an email if the order went through. Do not pay again." The Pay button stays gone.
4.3 Late answers (minutes 38 to 40)
Your decision. An answer from attempt 1 arrives after the user started attempt 2. Which one wins?
What I would say.
Say this. "Every event from an effect carries the attempt id. The transition table compares it with the id in the current state. A mismatch means the event is stale and is dropped."
4.4 Where iOS changes the design
The design is the same. The differences:
- The store is a class marked
@MainActor, so events come in one at a time on the main thread. The reducer is a plain function on a value type, usually an enum. - The foreground signal is the scene phase.
activemeans the scene is in the foreground and interactive, andbackgroundmeans expect the app to end soon (Apple ScenePhase). When the phase becomesactive, send AppRestored so that an Unknown flow checks again. - SwiftUI re-creates views often. The state must live in the store, not in view-local
@State.
5. Break it, ship it, roll it back (minutes 40 to 45)
In short: Each failure ends in a named state with a clear screen, never a spinner without an exit.
| Failure | User sees | Data holds | Recovery | Basis |
|---|---|---|---|---|
| Double tap on Pay | One spinner | One attempt id | Second event dropped | Design |
| No answer after 30 s | "Checking your payment" | Attempt id, state Unknown | Status check with delay | Design |
| App killed mid-payment | Checking screen on return | Saved attempt id | Start in Unknown | Design |
| Declined card | Error with Edit | Nothing saved | New attempt id on retry | Design |
| Answer for an old attempt | No change | Current attempt id | Event dropped | Design |
| Status stays pending | "Do not pay again" | Attempt id | Stop checking, email follows | Design |
The "User sees" cells are design intent, not measured behavior.
How I would test it: the reducer is a pure function, so a table-driven test sends every state and event pair and checks the result. A second test checks that no sequence of events reaches a state that is not in the list. Both run in milliseconds with no screen. I would add one instrumented test that ends the process in Submitting and checks that the app starts in Unknown.
What I would say.
Say this. "I cannot take back a build, so a bug in the table ships to everyone. I keep the table in one file with its tests, I add a kill switch for new payment methods, and I watch the share of sessions that end in Unknown. A rise in Unknown is an early signal. I left out several flows that talk to each other and tooling for time travel debugging."
Check yourself before section 6
- The user taps Pay twice, 200 ms apart. Which part of the design stops the second payment, and what must it not rely on? Answer: the transition table, where PayTapped in Submitting changes nothing. It must not rely on the button being disabled.
- The app is killed after the request left. Why is Editing the wrong state on return? Answer: the money may already have moved, and Editing shows Pay again.
- A swipe-away loses SavedStateHandle on Android. What holds the attempt id instead? Answer: DataStore or Room, written before the request leaves.
- An answer for attempt 1 arrives during attempt 2. What happens? Answer: the attempt ids differ, so the event is dropped.
6. Follow-ups that change a constraint
In short: Each follow-up changes one constraint. A strong answer says what breaks.
Practice prompts, not a claim about what any interviewer asks.
- Product adds a coupon step that can fail on its own. Strong: a new state and two events, and the table shows which rows change. Nothing else moves. Weak: "add another flag."
- The server forgets attempt ids after 24 hours. Stripe v1 may prune keys once they are at least 24 hours old, and a reused key then makes a new request (Stripe). Strong: past the window, the status check is the only safe call, and a new attempt needs a new id after the status says not paid. Keep the Unknown timeout well under the window. Weak: "reuse the same id forever."
- The flow has to survive the user switching to another app for an hour. Strong: the saved record is the source of truth, a scene phase event triggers a check, and the screen is rebuilt from state. Weak: "keep it in memory."
- Two screens need to react to the same flow, such as a cart badge and the checkout. Strong: one store, many readers of the same state. Neither screen owns a copy. Weak: "each screen keeps its own flags."
- The state list grows to 25 states. Strong: split into nested machines, such as a payment sub-machine inside the checkout, and keep each table small. Name the cost: more events pass between machines. Weak: "it is fine, one big enum."
- QA cannot reproduce a stuck state from a bug report. Strong: log the event list and replay it into the reducer. This works because the function is pure. Weak: "add more print statements."
7. What changes by level
In short: As I read it, the levels differ in who raises the problem.
These levels are my own reading of public prep material, not any company's rubric.
- Mid-level: a list of states instead of flags, events, a reducer, and a screen that only draws. I want to hear: "The screen sends events and draws the state."
- Senior: double taps handled in the table, a saved attempt id, an Unknown state, and stale events dropped. I want to hear: "A lost answer is a state, not an error."
- Staff: one pattern for all flows in the app, tests that check the table, replay from logs, and a rule for when a flow gets its own machine (staff guide). I want to hear: "Every flow that moves money or user data follows the same shape, and the tests enforce it."
Common weak answers. Booleans for states. A disabled button as the only guard. Starting at Editing after a restart. No saved attempt id. Network calls inside the click handler. An error state for a lost answer. These repeat across public prep material. They are not a dataset.
Staff answer: the decision and the rollout
- Decision: a small hand-written reducer per flow, a closed list of states and events, effects as data, and a saved record only for steps that cost money.
- Alternative: a statechart library with a visual editor. It gives diagrams and a shared language. It costs a dependency and a learning curve. I would revisit when more than five flows share the pattern.
- Revisit trigger: a flow with more than about ten states, or a second team that needs to read the table.
- Rollout: move the checkout first, behind a flag, with the old code as the fallback. Compare double-charge reports and the share of sessions that end in Unknown between the two groups. Then move the next flow.
- Guardrail: a test that fails when a state-event pair has no row. A rise in the Unknown share pauses the rollout.
Opinion vs verified: I read the cited pages on 15 October 2026. The state list, transition table, the Unknown state, timeouts and level text are my opinion.
Further reading (official docs)
- Design a hotel or ride booking app: the same lost-answer problem for a payment
- Build it: a state machine on Android and iOS: code, tests and platform limits for this case
- Android saving UI state
- ViewModel saved state
- ViewModel overview (Android)
- Stripe idempotent requests
- Apple SceneStorage
- Apple ScenePhase
Words on this page (4)
- Idempotency key
- A unique ID that the app sends with a request. It lets the server see a repeat request and not do the work twice.
- Process death
- The system ends the process of your app to free memory. Anything that is only in memory is lost.
- State machine
- A small model of a flow. It lists every state the flow can be in and every event that moves it from one state to the next.
- ViewModel
- A class that holds the state of one screen. It survives a screen rotation.
Verification notes (last reviewed 15 Oct 2026)
All section order, decision beats, spoken scripts, failure table cells, strong and weak notes and level text are new wording and my opinion. I fetched these pages on 2026-10-15 and wrote down what they say. Android saving UI state: the system may destroy the app process while the user is away; ViewModels are destroyed in a system-initiated process death; saved state survives it; none of these is long-term storage, and a database or DataStore is for that. Android ViewModel saved state: SavedStateHandle persists after a system kill, is lost on force stop, swipe from recents or reboot, and must be simple and lightweight. Stripe idempotent requests: the Idempotency-Key header on POST; the first result is saved, including 500 errors; keys may be pruned after at least 24 hours on API v1; the same key with different parameters is an error; keys up to 255 characters. Apple SceneStorage: lightweight data only; the system does not guarantee when or how often it is written; destroyed with the scene; not for sensitive data. Apple ScenePhase: active is in the foreground and interactive; on entering background expect the app to end soon. Not verified: that your server offers a lookup by attempt id (I assume it does and name it in the contract); the 30 second timeout and the check delay. The state list, transition table, Unknown state and replay table are my design. The follow-up questions are my reconstruction of challenges that public prep material reports, not transcripts of real rounds. The level text is my reading, not a company rubric. The 45 minutes is the interview length, not a documented fact.
Keep reading
Get the free one-page cheat sheet
One page: the five steps, a 45-minute time plan and two checklists. Print it or keep it on your phone.
We email you the cheat sheet after you confirm your address. You can also open it now from the Start here page. You get a short note when a new case is published. Unsubscribe any time. Privacy.