Build it. The database schema, API, code and platform limits for an offline-first travel plans app are on a separate page: Build it: an offline-first travel plans app on Android and iOS (16 min read).
How to read this. This page is a 45-minute round (the length I assume), in the order I would spend it. Schema, API, code and platform tables are in the build guide. The follow-up questions are my reconstruction of the challenges that public prep material reports. They are not transcripts of real rounds.
TL;DR. The hard part is not the sync. It is deciding what a companion may lose. My answer: nothing a person typed is lost without a sign. The server decides, one field at a time. A time keeps the clock of its place.
1. Scope and constraints (minutes 0 to 4)
In short: Promise that nothing typed is lost silently. Keep photos and roles. Park live cursors, expenses and public links.
A prompt I would expect: "Design an app where friends plan a trip together, even on a plane with no signal."
I ask five questions and give a default for each:
- How many people, and how long offline? Two to six, and weeks.
- What do people edit? Items with a title, time, place, note and photos.
- What must a companion see? My change after their next sync.
- Which phones and roles? Android and iPhone. Owner, editor, viewer.
| In scope | Out of scope | Left out until you ask |
|---|---|---|
| Edit a trip offline, merge by field | Booking and payment | Shared expenses |
| Day order and time zones | Email parsing | Two people typing in one note |
| Photo upload, roles, invites | Flight status, map tiles | A public read-only link |
The phone does four things to this design.
| What the phone does | Requirement at risk | Deep dive |
|---|---|---|
| Stops the app while edits wait | No typed change is lost | Client structure (3) |
| Limits background work | A photo upload finishes | The photo (4.2) |
| Shows a network that does not work, such as an airport portal | A send is not lost or repeated | Data and contracts (2) |
| Runs on roaming data and a battery | Photos spare the data plan | Numbers (2), photo (4.2) |
Your decision. Someone wants the trip to work like a shared document. Do you promise that every offline edit merges, or that nothing typed is lost and real conflicts are shown?
First idea, and why it fails. Promise a shared document. This fails when two phones are away for days and both change the same field. An automatic merge then makes text that nobody wrote.
What I would say. The promise is no silent loss. A conflict (two changes to the same field) is shown, and one tap keeps my version.
Say this. "Nothing a person typed disappears without a sign. Different fields merge by themselves. If two people change the same field, the server keeps one value, and the other person sees a conflict with a keep-mine button. Live cursors, expenses and public links wait."
The follow-up question I expect: "Users will expect live cursors."
They are parked. Live cursors need a connection, and this product is defined by not having one. Follow-ups 2 to 4 return the other parked items.
45-minute plan
| Minutes | Step | Drawn by the end | Cut if behind |
|---|---|---|---|
| 0 to 4 | Scope and constraints | Scope list, the promise | Fewer questions |
| 4 to 14 | Data and contracts | Database as truth, field changes, four calls | The numbers |
| 14 to 22 | Client structure | Layers, who owns sending | One line per layer |
| 22 to 40 | Make the hard part work | Time, order, long gap, photo | Link sharing, then the original photo. Never the conflict display |
| 40 to 45 | Break it, ship it, roll it back | Top failures, the flag | Two failures and the flag |
2. Data and contracts (minutes 4 to 14)
In short: The local database is the truth. Each change is one field with a base revision. The server decides.
Your decision. Who decides which edit wins? The device clock and the whole item, or the server, one field at a time?
First idea, and why it fails. Keep a modified time on each item, and let the newest save win. This fails when one phone has a wrong clock. It fails again when two people change different fields of one item. The older field disappears.
What I would say. A waiting change is one field. It carries the base revision, the revision the person was looking at. A revision is a number the server raises with each accepted change. The server applies the change only if that field has not changed since. Otherwise it keeps its value and reports a conflict.
I reject last-writer-wins on the whole item. I also reject a CRDT (a data type that merges edits by itself).
The follow-up question I expect: "Why not a CRDT?"
A CRDT merges without a central decision. But its history grows, and both platforms must follow its rules exactly. Only long text needs it. A trip note is short, so a visible conflict is enough.
The follow-up question I expect: "Two people add an item at position 3 at the same time."
Each item has a position key, a text value that sorts between its neighbours. If both phones pick the same key, the item ID breaks the tie.
The contract has four calls:
- Snapshot: the whole trip and a cursor (a saved position in the server's list of changes).
- Changes: everything after a cursor, or "too old".
- Submit: a request ID and field changes. Each gets a result: applied, conflict or rejected.
- Photo: a place to upload one photo.
The request ID is the idempotency key (the chat case calls it clientId). It makes a retry safe: doing it twice has the same result as doing it once.
These are my own assumptions, not measurements:
| Assumption | Arithmetic | What it decides |
|---|---|---|
| 30 changed fields a day for 21 days offline, about 200 bytes each | 30 x 21 = 630 changes, 126 KB, 7 requests of 100 | Size is not the problem. The risk is one field sent twice, so one waiting row per field |
| 150 items of about 1 KB. Busy planning makes 150 changes a day, a quiet trip 20 | Snapshot 150 KB. Busy: 30 KB a day, so a snapshot wins after 5 days. Quiet: 4 KB a day, so after about 38 days | No fixed limit in days. The server says "too old" when the changes are larger than a snapshot |
| 120 photos of 4 MB, or 600 KB resized. A roaming link of 250 KB per second | 4 MB / 250 KB/s = 16 s each, 32 minutes in all. Resized: 2.4 s each, about 5 minutes | Upload the resized copy first. Send the original later, on Wi-Fi |
3. Client structure (minutes 14 to 22)
In short: Sending sits below the screen. A repository owns the outbox, and the platform decides how it is drained.
Your decision. Who owns the sending? The screen's ViewModel, a foreground service, scheduled background work, or the app's own outbox?
First idea, and why it fails. The ViewModel (the class that holds a screen's state). It starts each send in viewModelScope. But Android clears a ViewModel when its screen leaves the back stack (ViewModel). The app process can also stop at any time. Then a send in progress is lost or repeated.
What I would say. One local transaction writes the edit and one outbox row (a table of changes not yet sent). A repository owns the draining.
- On Android, WorkManager runs it, as one unique chain per trip. Android documents it for work that must run even if the app exits or the device restarts (task scheduling).
- On iOS, the app drains the outbox itself: after an edit, in the foreground, and when the network returns.
I reject a foreground service. An app that targets Android 12 or later cannot start one from the background, except in a few special cases (restrictions).
The follow-up question I expect: "On iOS, what if the person force-quits?"
The edits are already in the database, and the server ignores a repeated request ID. The photo goes back to queued at the next launch. I do not claim that iOS relaunches the app after a force-quit.
UI (Compose / SwiftUI)
│ observes
ViewModel
│
TripRepository ──────────────┐
│ reads, writes │ drains
Local database (truth) Outbox and sender
trips, items, outbox Android: WorkManager
▲ iOS: own drain
└───── writes ───── REST: changes, submit, photo upload
The screen and ViewModel are the UI layer, and the repository is the data layer (recommendations). Three rules:
- The UI never talks to the network. It reads the database.
- One writer drains the outbox.
- The screen shows the server value with the waiting changes applied on top.
4. Make the hard part work (minutes 22 to 40)
In short: Times keep their place's clock. A long gap needs a snapshot. A photo uploads alone.
4.1 What "10:00" means, and the order of a day (minutes 22 to 31)
Your decision. What does "10:00" mean? Do you store the moment, the wall clock, or both? And what orders a day?
First idea, and why it fails. Take the phone's zone, convert to UTC, and store the instant. This fails when the person plans from home, because the phone is in the wrong place. It fails again for a future event. If a government changes a zone rule, the instant then means a different local time. RFC 9557 says it does not address this case (RFC 9557).
Phones get rule updates at different times (Android).
What I would say.
Say this. "I store what the person meant: a local date and time, plus an IANA zone ID, a place name such as Europe/Lisbon. The exact moment is derived and cached for sorting and flight length. I never store a fixed offset. A day belongs to the item's own date. Inside a day, I sort by time, then by a position key for items with no time."
I reject fixed offsets, and UTC alone. An optional offset only picks between the two copies of a repeated hour. By default, Java takes the earlier one, and moves a skipped time forward (ZonedDateTime).
The follow-up question I expect: "Lisbon 10:00 to New York 13:00. How long is the flight?"
Eight hours, on 10 June 2027 with today's rules. Duration is arithmetic on instants. Lisbon is UTC+1 in June and New York is UTC-4. Subtracting the wall clocks gives three hours, which is wrong.
4.2 Coming back after three weeks, and a photo that dies (minutes 31 to 40)
Your decision. A phone comes back after three weeks. Do you replay the changes, take a fresh snapshot, or do something else?
First idea, and why it fails. Ask for everything since the last sync time. This fails when the phone clock is wrong. Deletes never appear in that list. A pull can also overwrite an unsent edit, so the text jumps back.
What I would say.
Say this. "The server gives an opaque cursor, a position the app never reads. The app asks for changes after it. If the server says too old, the app takes a snapshot, keeps its waiting changes, and sends them again. A waiting value always sits on top of the server value, so nothing jumps back. A photo is its own upload, with an offset that the server confirms."
I reject a cursor made from device time. An old standard has the same shape: a server may reject an old token, and the client starts again (RFC 6578).
The follow-up question I expect: "A companion deleted an item that you edited."
The delete wins. Your edit comes back as a conflict with a Restore button. Deleting someone's work silently is the worse failure.
- A delete is a tombstone, a row that says "deleted", kept for a while.
- A phone that was offline learns about the delete from it.
The follow-up question I expect: "The upload died at 70 percent" (example).
The app asks the server for its offset and continues from there. It does not start again.
4.3 Where iOS and newer Android change the design
- A background upload on iOS must come from a file. Apple describes a growing delay for new background download tasks. I assume uploads are the same, so I use one session (Apple).
- For a transfer the person starts, Android 14 (Android) and iOS 26 (Apple) have newer paths. The Android page shows a download example, so I do not claim uploads. The system can stop both, so I save the offset.
5. Break it, ship it, roll it back (minutes 40 to 45)
In short: Each failure ends in saved data, a clear message, or a late update, never a silent loss.
| Failure | User sees | Data holds | Recovery | Basis |
|---|---|---|---|---|
| Lost acknowledgement | One change, no error | Request ID on each submit | Send the same request ID; the server returns the first result | Design intent; needs a server that honours the request ID |
| Process dies mid-write | The edit is still there | The edit and its outbox row | The next drain sends it | Running reliably after a restart is documented (WorkManager); the rest is design intent |
| Long offline gap | The trip, then "catching up" | Cursor and outbox | Too old; snapshot; send the waiting changes again | Design intent; RFC 6578 also lets a server reject an invalid token |
| Interrupted large transfer | "Uploading" | The offset the server confirmed | Ask for the offset and continue | File-only background upload is documented (Apple); the offset call is design intent |
| Account switch with pending work (a companion is removed) | "No access"; their text is kept | The rejected changes | Server answers 403; the phone deletes the trip after a confirm | Design intent |
| Item deleted while another person edits it | A conflict with Restore | A tombstone | Restore sends a new change | My reading |
Releases:
- The stores let me pause a rollout, not recall it. On Play, a halt leaves users who have the build on it (Play Console). On Apple, a phased release can pause for up to 30 days (Apple). That page does not mention reverting installed copies.
- The kill switch is a remote flag (a setting the server can change). It turns the new merge path off and falls back to snapshot pulls. It is the only rollback that reaches installed apps.
- A local schema change cannot be flagged off. It ships with a migration, one release before the code that needs it.
Say this. "I cannot recall a bad build, so my rollback is the flag plus a fixed build. I would watch the conflict rate and the too-old rate. I left out expenses, live cursors and link sharing."
6. Follow-ups that change a constraint (no minutes)
In short: Each follow-up changes one constraint. A strong answer says what breaks.
Practice prompts, not a claim about what any interviewer asks.
- A phone was away for six weeks, and the trip has 900 changes. Strong: the server says too old. The app takes a snapshot, keeps its waiting changes, sends them again and shows the conflicts. Weak: "Sync everything again and overwrite."
- Add shared expenses. Strong: each expense is an append-only entry, so two additions never conflict. The balance is computed. Weak: one editable total, which two phones overwrite.
- Two people type in the same note. Strong: per-field merge is not enough for one long text. Use a text merge for that one field, or show both versions. Weak: a CRDT for the whole trip.
- A read-only link for family, and a companion is removed. Strong: the server checks every request. The link is a token with an expiry. Removal arrives as a change, and the phone deletes the trip after a warning. Weak: hide the buttons on the client.
- After release, people report deleted items coming back. Strong: pause the rollout and switch the flag to snapshot pulls. Compare tombstone retention with how long phones stay away. Weak: "We ship a fix next week."
- The time model changes to wall clock plus zone, and many users run old builds. Strong: send both fields for a while. Old clients read the instant, and new clients read the wall clock. Retire the old field with a minimum version. Weak: force an update.
7. What changes by level
In short: As I read it, levels differ in who raises the problem, and in whether a rollout plan comes with it.
These levels are my own reading of the bar, drawn from vendor and prep material, not any company's rubric.
- Mid-level: the local database as truth, edits sent with a retry, photos in the background. They say how two edits are handled. I want to hear: "I never lose what the person typed."
- Senior: field changes with a base revision, request IDs, position keys, wall clock plus zone, an opaque cursor. I want to hear: "When two phones disagree, here is what the user sees."
- Staff: the merge decision and its cost, one protocol tested on both platforms, tombstone retention tied to the offline gap, and a kill switch. I want to hear: "Here is the guardrail that halts the rollout."
Common weak answers. These repeat across public prep material.
- Last write wins on the whole item.
- Naming a CRDT with no reason.
- Device time as the order or the cursor.
Staff answer: the decision and the rollout
- Decision: keep per-field merge with the server as arbiter, in one sync engine with a small contract: add a change, watch the waiting state, resolve a conflict. Test both platforms against shared test cases.
- Alternative: a CRDT or an operation-log library for the whole trip. It costs history growth and a protocol that is hard to change in the field.
- Revisit trigger: a need that per-field merge cannot express, such as two people typing in one long text.
- What to measure: conflicts per 1,000 changes, the share resolved with keep-mine, age of waiting changes, the too-old rate.
- Rollout: the server keeps the old change format. The new merge path sits behind a flag, with the old path kept in the app. See the staff mobile loop guide.
- Guardrail: no rise in reports of lost edits or of deleted items coming back.
Opinion vs verified: I read the offline-first, WorkManager, background session, zone and rollout facts in official docs. The per-field choice, the wall-clock model, the numbers, the metrics and the rollout plan are my opinion.
Further reading (official docs)
- Build it: an offline-first travel plans app on Android and iOS: schema, REST, code and platform limits for this case
- Build an offline-first app (Android): lazy writes, queues, pull, push and hybrid sync, last write wins
- Task scheduling and WorkManager (Android)
- User-initiated data transfer jobs (Android)
- Time zone rules (Android)
- Verify Android App Links
- Downloading files in the background (Apple)
- BGContinuedProcessingTask (Apple)
- TimeZone (Apple)
- RFC 9557: timestamps with zone names
- RFC 6578: collection synchronization
- Related case studies: Chat app, Photo and file sync, Booking app
Verification notes (last reviewed 13 Oct 2026)
this is the interview part of the case. The earlier draft became the build guide on 2026-10-14. The section order, the decision, first-idea and follow-up beats, the spoken scripts, the failure table cells, the strong and weak notes and the level text are new wording and my opinion. The choice to store a wall clock plus a zone ID, with the instant derived, is a design decision. It changes the earlier draft, which stored the instant. The sources are RFC 9557 and the Android zone page, not an interview source. Audit pass on 2026-10-14: I re-read the Android UIDT, foreground-service, App Links, Room, Play rollout and WorkManager define-work pages, the Apple background session, BGContinuedProcessingTask and phased release pages, and RFC 9557. I corrected two claims. RFC 9557 does not state that a stored instant changes. It names the case as out of scope, so the page now says that. The Apple rate limiter text speaks of download tasks, so uploads are my assumption. The foreground-service rule applies to apps that target Android 12 or later. I read these pages on 2026-10-14. The Android offline-first page says lazy writes can conflict when the app comes back online. It says conflict resolution often needs versioning and describes last write wins with timestamps. It says to drain a write queue with exponential backoff, and to use a persistent queue in Room for firmer ordering. It describes pull, push and hybrid sync. The WorkManager persistent-work page says work persists across restarts and reboots. The ViewModel page says a ViewModel is cleared when a Navigation entry leaves the back stack. The foreground-service page says apps that target Android 12 or higher cannot start a foreground service from the background, with exceptions. The user-initiated transfer page covers Android 14 and later, needs a notification and says the system or the person can stop the job. It speaks only of downloads, so I do not claim uploads. The Android zone page says rule updates come through a system module on Android 10 and higher, and sets no common timing. RFC 9557 says a stored future local time can change its instant when zone rules change. The Java ZonedDateTime page says a time in a gap moves forward and an overlap keeps the earlier offset by default. RFC 6578 says clients treat the token as opaque, and a server may reject an old token so the client starts again. The Android media page says apps on Android 10 and higher need ACCESS_MEDIA_LOCATION for unredacted photo location. Apple background session page says only uploads from a file continue after the app exits, a terminated app is relaunched and the session is recreated with the same identifier, and a rate limiter delays new background tasks. The BGContinuedProcessingTask page says a person can cancel it and the system can end it abruptly. The Play staged rollout page says users who have the build stay on it. The Apple phased release page allows 30 days of pause and says nothing about reverting. Not verified: that iOS never relaunches the app after a force-quit during a general background upload, any Apple page on invite links and the not-installed fallback, whether photo location is removed by default on iOS, push delivery under Doze, and any CRDT library. The "too old when the changes are larger than a snapshot" rule, the tombstone retention and the 403 handling are design choices, not documented values. The three numbers in section 2 are arithmetic on stated assumptions, not measurements, and I recomputed them: 630 x 200 B is 126 KB, 150 / 30 is 5 days, 150 / 4 is 37.5 days, and 120 x 16 s is 1,920 s. The photo speed is an assumption, and I give no radio or battery figure. The Lisbon to New York example is eight hours with today's rules: UTC+1 and UTC-4 in June. The follow-up questions and follow-ups 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. No sentence on this page claims how often this prompt is asked. The Basis column marks documented behavior, design intent or my reading, and the User sees cells are design intent. Source-check pass, 2026-10-14: I changed the WorkManager, foreground service, RFC 9557 and RFC 6578 wording to match the pages, marked an example, said that the 45-minute length is my assumption, and cut about 80 words to stay within 2,900.
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.