Mobile Design Rounds
Build guideAndroidiOS16 min read

Build it: an offline-first travel plans app on Android and iOS

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: Design an offline-first travel plans app (Android and iOS) · The framework.

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:

  • trip
  • itinerary_item, with sortKey, the time fields, serverRev, a deletedAt tombstone and a dirty flag
  • op_outbox(opId, tripId, itemId, field, value, baseRev, createdSeq), one row per waiting field change, ordered by a local counter
  • media, the upload queue, in the style of photo and file sync
  • sync_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:

  1. A local edit writes the value into Room. It also adds one op_outbox row for that field. The row carries the baseRev the person was looking at.
  2. PushChangesWorker posts the waiting ops for the trip, in order.
  3. 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.
  4. 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?").
  5. 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 baseRev and the last value.
  • Or move the baseRev forward 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.

Phone Aoffline: new namePhone Boffline: new timeadds a note11Serverapplies a field if it has not changed sincethe person's base revision (baseRev)Both edits survivethe next pull gives both phones this itemSame field edited to a different value?The server keeps its value, flags a conflict
Two phones edit one item offline. The server checks each field against the base revision the person saw, so both edits survive, and a clash on one field is shown, not hidden.

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.of throws 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). ZonedDateTime moves 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 stale baseRev then 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_outbox applied 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

ScenarioHandling
Response lost after the server applied opsRetry with the same opId; the server returns the first results
Same field edited on two phonesThe server keeps its value and returns a conflict; the UI offers "keep mine" as a new op
Delete on one phone, edit on anotherTombstone with a revision; the edit comes back as a conflict with a restore action
Device offline longer than tombstone retention410 on the cursor; fetch the snapshot, keep waiting ops, check them again
Pull overwrites a waiting editShow the server value with op_outbox applied on top; no jump
Companion removed while offlineQueued 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.

LimitAndroidiOSSo the design changes by
Work that must survive a restartWorkManager work persists across app restarts and rebootsNo equivalent; the app drains its own outboxThe outbox is a table, and a drain runs on edit, foreground, network return and push
Background uploadPersistent WorkManager work; Android 14 adds user-initiated transfer jobs, and the page speaks of downloadsA 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 endedNot covered hereThe system relaunches the app if it ended it; I did not confirm this for a force-quitOn launch, set a photo row to queued and ask the server for its offset
RetriesResult.retry() with exponential backoff, 30 s by default and 10 s at the leastNo equivalent; the outbox decidesRetry short failures; stop on a permanent rejection
Photo locationHidden on Android 10 and later unless the app holds a permissionNot checkedTreat location as a product choice; make no promise
Zone rule updatesCome through a system module at different timesTimeZone IDs are names of places; update timing not readStore the local value and the zone ID
Invite linksVerified links open the app; re-checks on Android 15 take up to seven daysNot read in fullLink 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 repeated opIds, so nothing is lost.
  • The system owns background transfers. Apple's background URLSession runs in a separate process. It supports only upload tasks from a file, so write each photo to a file and use uploadTask(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.
  • BGProcessingTask is a bonus, not a plan. It runs only when the device is idle, and it can be interrupted. It needs the processing background mode. Set requiresNetworkConnectivity, 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

  1. 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.)
  2. How would you add live presence? (A socket only while the trip is open, kept short-lived. The durable path stays the op log.)
  3. How would you add shared expenses? (Append-only entries, and a balance computed from them.)
  4. How would you add offline maps? (This is a download problem, not a conflict problem. Keep it apart from the edit path.)
  5. How would you add calendar export? (Export the local value with its zone ID.)

Further reading (official docs)

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.