This is the implementation material behind the travel plans app interview case. It covers:
- the local schema, the REST calls and the merge rule
- order keys, deletes, time zones and the sync cursor
- photos, roles, platform limits and the iOS version
None of it belongs in a 45-minute round (the length I assume). I draw it only if asked. The snippets use Room 2.x (androidx.room), Android's local database library. The official Room guide now shows Room 3 (androidx.room3). The design is the same either way. The model is the one on the offline-first hub.
1. Local schema (Room)
The local tables are:
tripitinerary_item, withsortKey, the time fields,serverRev, adeletedAttombstone and adirtyflagop_outbox(opId, tripId, itemId, field, value, baseRev, createdSeq), one row per waiting field change, ordered by a local countermedia, the upload queue, in the style of photo and file syncsync_state(scope, cursor)
@Entity(
tableName = "itinerary_item",
indices = [Index("tripId", "sortKey", "itemId")], // order by sortKey, break ties by itemId
)
data class ItineraryItemEntity(
@PrimaryKey val itemId: String, // client-generated UUID
val tripId: String,
val title: String,
val sortKey: String, // fractional index
val startInstant: Long, // epoch millis
val startZoneId: String, // IANA id, e.g. "Europe/Lisbon"
val serverRev: Long, // last revision the server confirmed
val deletedAt: Long? = null, // tombstone: hidden in the UI, kept until the server confirms
@ColumnInfo(defaultValue = "0") val dirty: Boolean = false,
)
I show the entity itself to keep the snippet short. In a larger app, I would map it to a UI model. itemId is made on the device. So creating an item offline needs no round trip, and a retried create does no harm. The chat case does the same with clientId.
A change since the compiled version. The interview page stores what the person meant: a local date and time plus a zone ID. The entity above, as compiled, stores the instant. To follow the interview page, add a text column such as startLocal (for example 2027-06-10T10:00). Then treat startInstant as a cache that you can compute again. I did not compile this change.
2. REST
GET /v1/trips/{id}/changes?cursor=<opaque> → { changes[], nextCursor, hasMore } or 410 (cursor too old)
GET /v1/trips/{id} → full snapshot + cursor (bootstrap and resync)
POST /v1/trips/{id}/ops { opId, ops[] } → per-op result: applied | conflict(field, serverValue) | rejected
Every write carries an opId. So a retry after a lost response changes nothing on the server. The server picks the revision and the cursor. They are not the seq of the chat case.
A 410 on the changes call means the cursor is too old. The client then fetches the snapshot (section 7).
3. Merging edits: the rule
Two companions are on a plane. One renames "Lunch" to "Lunch at Time Out Market". The other changes the time of the same item and adds a note. Both edits must survive.
Whole-item last write wins loses one of them. The Android guide names last write wins as the simple option. It also says that conflict resolution often needs versioning, with the network as the source of truth. So the flow is:
- A local edit writes the value into Room. It also adds one
op_outboxrow for that field. The row carries thebaseRevthe person was looking at. PushChangesWorkerposts the waiting ops for the trip, in order.- The server applies an op if the field has not changed since
baseRev. If the field changed to another value, the server keeps its value and reports a conflict for that field. - The client clears the applied ops. For a conflict, it shows the server value and keeps the rejected value in view ("Alex changed this to ... Keep mine?").
- The next pull brings every accepted change, and revisions make all devices agree.
// Sketch of the server rule: reject only a field that changed since the editor's base revision
data class Field(val value: String?, val rev: Long)
data class Edit(val name: String, val value: String?, val baseRev: Long)
data class MergeResult(val fields: Map<String, Field>, val conflicts: List<String>)
fun mergeEdits(current: Map<String, Field>, edits: List<Edit>, newRev: Long): MergeResult {
val out = current.toMutableMap()
val conflicts = mutableListOf<String>()
for (e in edits) {
val cur = current[e.name]
if (cur != null && cur.rev > e.baseRev && cur.value != e.value) conflicts += e.name
else out[e.name] = Field(e.value, newRev)
}
return MergeResult(out, conflicts)
}
This code runs on the server in the real system. It is a sketch of the rule.
Known gap, found by reading. The sketch compares each edit's baseRev with the revision stored before the batch. Suppose one person edits the same field twice offline, and the two ops travel in two requests. The second op then sees the revision made by the first op. It looks like a conflict with the person's own change. There are two fixes:
- Merge the waiting ops for a field into one, keeping the first
baseRevand the last value. - Or move the
baseRevforward when the server accepts an op.
I did not compile or test either fix. Write the two-request test first.
Per-field merge treats a field as one value. So two people typing in one long note still conflict. A CRDT (a data type that merges edits by itself) can merge that. But both platforms must then follow its protocol exactly and keep its history.
My position: per-field merge for structured fields, a text merge for the note only if the product needs it, and no CRDT for the whole trip. I have not checked any mobile CRDT library, so I do not recommend one.
Do not merge two edits to the same field by joining the text. That invents text nobody wrote. "Your change was not applied, reapply?" is cheap and honest.
4. Order keys
With integer positions, an insert at the top rewrites every row after it. Two offline inserts at position 3 also collide. Give each item a sortKey, a string that sorts between its neighbours. Sort by (sortKey, itemId). A move or an insert then writes one row.
The interview page sorts a day by time first. The key decides only the items with no time, and ties.
private const val DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
fun keyBetween(a: String?, b: String?): String {
require(a == null || b == null || a < b) { "a must sort before b" }
return mid(a ?: "", b)
}
// Keys never end in '0', so there is always room between two distinct keys.
private fun mid(a: String, b: String?): String {
if (b != null) {
var n = 0
while (n < b.length && (if (n < a.length) a[n] else '0') == b[n]) n++
if (n > 0) return b.substring(0, n) + mid(a.drop(n), b.drop(n))
}
val da = if (a.isEmpty()) 0 else DIGITS.indexOf(a[0])
val db = if (b == null) DIGITS.length else DIGITS.indexOf(b[0])
return when {
db - da > 1 -> DIGITS[(da + db + 1) / 2].toString()
b != null && b.length > 1 -> b.substring(0, 1)
else -> DIGITS[da] + mid(a.drop(1), null)
}
}
- Concurrent inserts can produce the same key. The
(sortKey, itemId)tie-break is the same on every device, so the order is the same everywhere. - Keys grow when people insert again and again at one spot. For human edits this is rare. Rebalancing a day rewrites many rows and can meet a concurrent move. I leave it as an open item.
- Runs of inserts at one spot can interleave with another person's run. I accept this.
5. Deletes and tombstones
If a delete removes the row, a companion who edited that item offline uploads it again on reconnect. The item comes back. So a delete is a write. Set deletedAt and a revision, sync it, and filter tombstones out of every UI query.
If one person deletes an item while another edits it offline, my rule is that the delete wins. The edit comes back as a conflict that the editor can restore ("This item was deleted. Restore it?"). Deleting someone's work silently is the worse failure.
The server cannot know whether a phone that was offline for months has seen the tombstone. So it keeps tombstones for a retention time. It answers an older cursor with a forced resync (section 7). Tie the retention to the offline gap you promise. I give no number, because no official page gives one.
Order keys need no tombstones. Only the change feed does.
6. Time zones
A trip crosses zones, so what does "10:00" mean? Store a local date and time plus an IANA zone ID such as Europe/Lisbon. Never store a fixed offset like +01:00, and never a display name. Apple says the same: values such as America/Los_Angeles are IDs, not names (TimeZone).
The instant is derived from those two values. An instant alone loses the place the user meant. Then the UI cannot show "10:00 in Lisbon" to a companion in New York.
data class ItineraryTime(val instant: Instant, val zoneId: String) {
fun wallClock(): LocalDateTime = instant.atZone(ZoneId.of(zoneId)).toLocalDateTime()
companion object {
fun fromWallClock(local: LocalDateTime, zoneId: String) =
ItineraryTime(local.atZone(ZoneId.of(zoneId)).toInstant(), zoneId)
}
}
fun flightDuration(dep: ItineraryTime, arr: ItineraryTime): Duration =
Duration.between(dep.instant, arr.instant)
The snippet builds the instant from a wall clock (fromWallClock) and reads the wall clock back (wallClock()). It holds the instant as the main value, as in the compiled version. With the model on the interview page, the stored local value is the truth, and you compute the instant again from it.
- Display in the item's own zone, with a switch for the viewer's zone.
- Duration is arithmetic on instants. Lisbon 10:00 to New York 13:00 on 10 June 2027 is eight hours with today's rules. Lisbon is UTC+1 and New York is UTC-4. Subtracting wall-clock times gives three.
- Group by the item's local date. For example, a 23:30 item in Tokyo belongs to that Tokyo day for every viewer.
- An unknown zone ID.
ZoneId.ofthrows for an ID that this phone does not know, for example a new ID on an old phone. Catch it and show the stored local time with the ID. The Swift model falls back to GMT. I did not compile this handling. - Take the zone from the place, not from the device. The device zone is wrong when you plan a trip from home.
- Gaps and overlaps. A local time may not exist (clocks go forward) or may happen twice (clocks go back).
ZonedDateTimemoves a time in a gap forward by the length of the gap. In an overlap, it keeps the earlier offset unless you ask for the later one (Java SE 17). - Rules change. A government can change a zone rule after you store an instant. RFC 9557 names the case of a future local time whose instant changes, and says it is outside its scope (RFC 9557). Android updates zone rules through a system module, and phones get an update at different times (Android). That is why the local value is the truth.
7. The sync cursor
Pull sync needs an answer to "what changed since I last looked". Use a server-ordered change log and an opaque cursor, a value that the app never reads.
- A steady order per trip. Every accepted op, tombstone and permission change goes into the log. The cursor means "the last change you have seen". The server can change its encoding, because the client never reads it.
- Never use device time as the cursor. A wrong clock or a late commit breaks "changes since my last sync time".
- Apply a page and its cursor in one Room transaction. If the process dies, you have both or neither. Pulling again does no harm, because applying by
(itemId, serverRev)ignores anything that is not newer. Your own pushed writes come back in the next pull and change nothing. - Put tombstones and permission changes in the log. Otherwise a pull never learns about them.
- Expired cursor. The server answers
410. The client fetches the snapshot and replaces the local copy. It keeps its waiting ops. A stalebaseRevthen goes through the normal per-field check. - Waiting edits survive a pull. A refresh that overwrites a row with an unsent edit makes the text jump back. Show the server value with the waiting ops from
op_outboxapplied on top. - A push is a hint. "Trip X changed" starts a pull. One pull recovers any number of missed hints.
An old standard, RFC 6578, has the same shape. It has an opaque token, a full list from an empty token, and an error for a token that is too old (RFC 6578).
8. Photos, roles and sharing
A photo is a separate upload from the item edit. A media row starts as QUEUED. The item points at it at once, with a local thumbnail. A unique UploadMediaWorker for each mediaId runs a resumable session. It saves confirmedOffset in Room. The mechanics are in photo and file sync.
After the upload is complete, the client calls the attach endpoint. Only then do companions see the photo. Say "shared" after that point, not before.
Photo location is a privacy choice. On Android 10 and later, apps cannot read it unless they hold the ACCESS_MEDIA_LOCATION permission (media). I have not checked what iOS does, and I do not promise that location is removed.
Roles are enforced on the server. A viewer's op must be rejected whatever the client sends. A queued op that gets 403 is marked rejected, and the text is kept. Removal arrives as a change in the log. The app deletes the local trip after it warns about unsent edits. It cannot reach a phone that never reconnects.
For invite links, verified App Links open the app directly on Android. On Android 15 and later, the system re-checks links in the background, and a change can take up to seven days to reach all phones (App Links). The page does not cover a phone without the app. The web page fallback is my design.
9. Scenarios and handling
| Scenario | Handling |
|---|---|
| Response lost after the server applied ops | Retry with the same opId; the server returns the first results |
| Same field edited on two phones | The server keeps its value and returns a conflict; the UI offers "keep mine" as a new op |
| Delete on one phone, edit on another | Tombstone with a revision; the edit comes back as a conflict with a restore action |
| Device offline longer than tombstone retention | 410 on the cursor; fetch the snapshot, keep waiting ops, check them again |
| Pull overwrites a waiting edit | Show the server value with op_outbox applied on top; no jump |
| Companion removed while offline | Queued ops get 403; keep the text, explain, delete local data after confirmation |
No polling loop. Pull on open, on foreground, when the network returns and on a push hint.
10. Platform limits
The limits that shaped the design, side by side. Each fact comes from a page I read, or is marked as my reading.
| Limit | Android | iOS | So the design changes by |
|---|---|---|---|
| Work that must survive a restart | WorkManager work persists across app restarts and reboots | No equivalent; the app drains its own outbox | The outbox is a table, and a drain runs on edit, foreground, network return and push |
| Background upload | Persistent WorkManager work; Android 14 adds user-initiated transfer jobs, and the page speaks of downloads | A background session continues only uploads from a file, and a rate limiter delays new background download tasks (uploads assumed) | Write the photo to a file, use one session, and save the offset |
| After the app is ended | Not covered here | The system relaunches the app if it ended it; I did not confirm this for a force-quit | On launch, set a photo row to queued and ask the server for its offset |
| Retries | Result.retry() with exponential backoff, 30 s by default and 10 s at the least | No equivalent; the outbox decides | Retry short failures; stop on a permanent rejection |
| Photo location | Hidden on Android 10 and later unless the app holds a permission | Not checked | Treat location as a product choice; make no promise |
| Zone rule updates | Come through a system module at different times | TimeZone IDs are names of places; update timing not read | Store the local value and the zone ID |
| Invite links | Verified links open the app; re-checks on Android 15 take up to seven days | Not read in full | Link to a web page for people without the app |
11. How to build an offline-first travel plans app on iOS (Swift)
Same design, different primitives. SwiftData or Core Data (or GRDB) replaces Room. SwiftUI watches it with @Query. A background URLSession replaces the upload worker.
What really differs:
- You drain the outbox yourself. There is no WorkManager equivalent. Start a drain on edit, on foreground, on network return (
NWPathMonitor) and on push. If the user force-quits, do not count on a relaunch. Queued edits may wait for the next launch. I did not confirm this in the pages I read. The edits are in the database and the server ignores repeatedopIds, so nothing is lost. - The system owns background transfers. Apple's background
URLSessionruns in a separate process. It supports only upload tasks from a file, so write each photo to a file and useuploadTask(with:fromFile:). If the system ended the app, it relaunches the app. Create the session again with the same identifier. Use one session. Apple documents a growing delay for new background download tasks. I assume uploads match. BGProcessingTaskis a bonus, not a plan. It runs only when the device is idle, and it can be interrupted. It needs theprocessingbackground mode. SetrequiresNetworkConnectivity, and do the cheap work on launch and foreground.BGContinuedProcessingTask(iOS 26) starts in the foreground and may continue after. The person can cancel it, and the system can end it at once. Save the offset. The page I read does not name uploads.CKSyncEngine(iOS 17) syncs local records with CloudKit. It fits only when every companion uses Apple's cloud. A trip shared with Android phones needs your own server and outbox. That is my reading.- SwiftData upsert. Apple documents
@Attribute(.unique)as keeping a value unique across instances. Test upsert-on-save on your target OS before you rely on it.
@Model
final class ItineraryItem {
@Attribute(.unique) var itemId: String // client-generated UUID
var tripId: String
var title: String
var sortKey: String // fractional index
var startInstant: Date
var startZoneId: String // IANA id, e.g. "Europe/Lisbon"
var serverRev: Int64
var deletedAt: Date? // tombstone
var dirty: Bool
init(itemId: String, tripId: String, title: String, sortKey: String,
startInstant: Date, startZoneId: String) {
self.itemId = itemId; self.tripId = tripId; self.title = title; self.sortKey = sortKey
self.startInstant = startInstant; self.startZoneId = startZoneId
self.serverRev = 0; self.deletedAt = nil; self.dirty = true
}
var startZone: TimeZone { TimeZone(identifier: startZoneId) ?? .gmt }
}
The fractional-key logic and the merge rule are plain value code, and they port to Swift line by line. Port the unit test with them: random inserts stay sorted, strictly between their neighbours, and never end in the lowest digit.
12. More practice prompts
- How do you test this? (A fake server that drops, reorders and repeats requests. Kill the process in the middle of a push. Property-test the key function. Write the two-request test from section 3.)
- How would you add live presence? (A socket only while the trip is open, kept short-lived. The durable path stays the op log.)
- How would you add shared expenses? (Append-only entries, and a balance computed from them.)
- How would you add offline maps? (This is a download problem, not a conflict problem. Keep it apart from the edit path.)
- How would you add calendar export? (Export the local value with its zone ID.)
Further reading (official docs)
- The interview part of this case: scope, decisions, failures, follow-ups and levels
- Build an offline-first app (Android): write strategies, pull, push and hybrid sync, last write wins, queues
- Architecture recommendations (Android)
- Task scheduling and WorkManager (Android)
- Manage work (WorkManager): unique work and
ExistingWorkPolicy - User-initiated data transfer jobs (Android)
- Time zone rules (Android)
- Access media files and photo location (Android)
- Verify Android App Links
- ZonedDateTime (Java SE 17)
- TimeZone (Apple)
- Downloading files in the background (Apple): background sessions, file-based uploads, relaunch
- BGProcessingTask (Apple)
- BGProcessingTaskRequest (Apple)
- BGContinuedProcessingTask (Apple)
- Preserving your app's model data across launches (Apple)
- RFC 9557 and RFC 6578
- Offline-first hub on this site
- Related case studies: Chat app, Photo and file sync, Booking app
Verification notes (last reviewed 13 Oct 2026)
this guide is the implementation part of the travel plans case. I moved it to its own page on 2026-10-14 so the interview page stays inside 45 minutes. The five code blocks (the Room entity, the REST listing, the merge function, the fractional key function and the time snippet) and the Swift model are byte-identical to the earlier draft. I checked this with a diff on 2026-10-14. I compiled and ran the code again on 2026-10-14, adding only package and import lines. Kotlin 2.3.21, Android Gradle plugin 9.1.0, compileSdk 36, Room 2.8.4 and Gradle 9.8.1, all offline from the local cache, nothing downloaded. The Room entity, the merge function, the fractional key function and the time snippet compile with no errors. The Room compiler (KSP 2.3.6) also ran on the entity inside a small test database that I wrote, and it accepted the table, the index and the default value. That database and its two queries are stubs. In the audit I re-read the pages for UIDT, App Links, Room, the Play rollout, WorkManager define-work, the Apple background session (rate limiter text speaks of download tasks), BGContinuedProcessingTask and phased release, and RFC 9557. I corrected the RFC 9557 claim, which only names the case as out of scope, the force-quit claim, which I could not confirm, and the upload rate limit, which is my assumption. I ran the key, merge and time code on the JVM with a self-check: 2,000 random inserts stay sorted, unique and never end in the lowest digit; the merge keeps edits to different fields and flags the same field; the two-request case does report a false conflict, as section 3 says; the Lisbon to New York example is eight hours. The Swift model type-checked with swiftc 6.4 against the iOS 27.0 simulator SDK, for iOS 17 and iOS 26 targets, in language modes 5 and 6, with no errors or warnings. It was not run. I found no bug in the code. What is not compiled: the startLocal column in section 1, the two fixes for the two-request case in section 3, and the wall-clock-first time model in section 6. These are described in prose only. The two-request gap is my reading of the sketch and it is not tested. The fractional key algorithm follows the common midpoint approach and is our implementation. Sections 3, 4, 5 and 7 are our synthesis. The official docs do not cover collaborative editing, so per-field merge, order keys, tombstone handling and the cursor design are opinion. Read on 2026-10-14: the Android offline-first page (lazy writes and their conflict warning, versioning, last write wins, a queue drained with backoff, a persistent queue for firmer order, pull, push and hybrid sync); the WorkManager persistent-work page (work persists across restarts and reboots); the user-initiated transfer page (Android 14, notification, can be stopped, speaks of downloads only); the Android zone rules page (module updates, no common timing); the Android media page (photo location needs ACCESS_MEDIA_LOCATION on Android 10 and later); the App Links page (Android 15 re-checks, up to seven days); the Java SE 17 ZonedDateTime page (gap and overlap); RFC 9557 (future local times can change instant); RFC 6578 (opaque token, old token rejected); the Apple background session page, through its JSON (separate process, file-only uploads, relaunch and same-identifier session, rate limiter); and the Apple BGContinuedProcessingTask page, through its JSON (cancel by the person, abrupt end). Earlier, on 2026-10-13, I verified the manage-work page (enqueueUniqueWork, ExistingWorkPolicy), Apple TimeZone (IDs, not names), BGProcessingTask (idle-only, can be interrupted, processing mode), BGProcessingTaskRequest (requiresNetworkConnectivity) and SwiftData @Attribute(.unique). Not verified: that nothing relaunches the app after a force-quit in a general background upload; Android behavior after a force-stop; Apple's side of invite links and the no-app fallback; whether photo location is removed by default on iOS; NWPathMonitor details; SwiftData unique-upsert behavior on a given OS; the CKSyncEngine reading (it comes from the Apple page through its JSON, and I did not build with it); the WorkManager backoff numbers in section 10 (from the define-work page, as read for the chat case, and not re-read here); and any CRDT library. No numbers are claimed here. The 410 resync and the tombstone retention are design choices, not documented values. No iOS engineer has reviewed the iOS sections yet. Source-check pass, 2026-10-14: I changed the WorkManager link label, marked an example, and said that the 45-minute length is my assumption.
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.