The reading list keeps everything on one device. This last post of the chapter lets it sync: a small server, the same app on a phone and in a browser, and a Sync button. Syncing is easy to write and easy to get wrong. The hard part is what happens when devices change things while they cannot reach the server, because each of them is then right about its own changes and wrong about everyone else’s.

The post builds sync in a way that survives that, then measures it: seven stories in which two devices disagree, each run with the app’s own code against the real server, and each compared with the simpler design most first versions use. Then it runs the real thing: an Android phone, taken offline with airplane mode, and a browser change the same book, and the phone comes back.

1. Kotlin on Both Ends

The project gains two modules. sync is small, has no UI and no database, and compiles for every target the app has and for the server’s JVM: the format a book travels in, the rule for merging two versions of it, and a clock. server is a Ktor application of 19 lines of its own that keeps books in memory and answers one request.

// :sync, commonMain: the same code on the phone, in the browser, on the desktop, on iOS and on the server
@Serializable
data class SyncBook(
    val uid: String,                 // the book's identity on every device
    val title: String, val author: String, val createdAt: Long,
    val read: Boolean = false, val readAt: Long = 0,
    val notes: String = "", val notesAt: Long = 0,
    val deleted: Boolean = false, val deletedAt: Long = 0,
)
 
@Serializable data class SyncRequest(val since: Long, val changes: List<SyncBook>)
@Serializable data class SyncResponse(val cursor: Long, val changes: List<SyncBook>)
// :server
fun Application.readingListServer(store: ServerStore = ServerStore()) {
    install(ContentNegotiation) { json() }
    install(CORS) { anyHost(); allowHeader(HttpHeaders.ContentType); allowMethod(HttpMethod.Post) }
    routing {
        post("/sync") { call.respond(synchronized(store) { store.sync(call.receive<SyncRequest>()) }) }
    }
}

On the app’s side, Ktor’s client is common code, and each platform contributes only its engine: OkHttp on Android, Java’s HTTP client on the desktop, the browser’s fetch on the web, Darwin on iOS. Sharing the merge rule between client and server is what makes the rest of this post possible: a device and the server can never disagree about how two versions combine, because they run the same function.

a devicethe UIfollows the database (a Flow)Room: books, each with uid,per-field timestamps, deleted, dirtySyncerdirty rows out · merged rows inthe server (Ktor, JVM)ServerStoreevery book by uid, with the numberof the change that last touched itmerge(a, b)the same function as on the devices(the :sync module)since: 41, changes: [ … ]cursor: 44, changes: [ … ]Offline, the request fails and nothing changes: the rows stay dirty and go with the next round. Online, the server merges what it receivesand answers with everything that changed since the device last asked, its own changes included; the device merges those in.

One round of sync, in the app’s Syncer: send every row marked dirty, together with the position of the last change already received; the server merges each one and answers with every book that changed after that position, including the ones just sent; the device merges those into its database, and the screen, which follows the database, updates. Offline, the request fails and nothing changes: the rows stay dirty for the next round, and the screen says so.

suspend fun sync(): Int {
    val since = dao.setting(CURSOR)?.toLongOrNull() ?: 0
    val sent = dao.dirty()
    val response = api.sync(SyncRequest(since, sent.map { it.toSync() }))   // throws when offline: nothing changes
    db.withWriteTransaction {
        for (remote in response.changes) {
            val local = dao.byUid(remote.uid)
            if (local == null) dao.insert(remote.toBook(dirty = false))
            else dao.update(local.with(merge(local.toSync(), remote), dirty = …))
        }
        for (b in sent) dao.markSent(b.uid, b.readAt, b.notesAt, b.deletedAt)   // only if unchanged since it was sent
        dao.putSetting(Setting(CURSOR, response.cursor.toString()))
    }
}

2. Four Ways to Lose Data, and Four Defences

Every one of the following was found by running it, not by reading about it.

what happensa simple design loses……what this app does insteadBoth devices add a bookofflineidentity = local row number: both are book 4, oneoverwrites the othera uid per book, the same onevery deviceOne device deletes, theother still has itdeleting removes the row: the next sync brings itbacka tombstone: deleted = true, andit travelsA ticks it, B writes a noteone timestamp per row: the later row wins whole,the tick is losta timestamp per field: bothchanges keptB's clock is an hour behindwall-clock stamps: B's later edit looks older andis droppeda hybrid clock: never earlierthan what it has seenEach pair was run: the app with two devices and the real server over HTTP, the simple design as a small model of it. What no last-writer-winsdesign avoids: the same field changed on both devices while offline. One of the two edits is kept, by rule, and the other is gone.

Identity. Until now a book’s identity was its row number, AUTOINCREMENT. Two devices offline both create book 4, and whichever syncs second overwrites the other’s. Each book now has a uid, a random UUID made on the device that created it. The three sample books get fixed uids (sample-1 …), so that two devices which both started with the sample share three books rather than six.

Deletion. Deleting a book used to remove the row. Then the next sync from a device that still has it brings it back, because nothing says it was deleted. A deleted book is now a tombstone: deleted = true, hidden from the list, and synced like any other change. A deletion wins over every later edit: once deleted anywhere, deleted everywhere.

Granularity. One “last changed” time per book means the later version replaces the earlier one whole: if one device ticks a book while the other writes a note, one of the two changes is lost. Each field that can change now has its own time, and merge takes each field from whichever version changed it last.

Clocks. “Later” was decided by each device’s wall clock. A phone whose clock is an hour slow makes its newest edit look an hour old, so it loses to the edit it was replying to, and then disappears from the phone itself after the next sync. The app’s timestamps now come from a small hybrid clock: the wall clock, but never earlier than anything the device has already seen.

class HybridClock(private val wallClock: () -> Long, private var last: Long = 0) {
    fun now(): Long { last = maxOf(wallClock(), last + 1); return last }
    fun observe(timestamp: Long) { last = maxOf(last, timestamp) }   // every timestamp that arrives from elsewhere
}
fun merge(a: SyncBook, b: SyncBook): SyncBook {       // per field: the later change wins; a deletion always wins
    fun <T : Comparable<T>> pick(va: T, ta: Long, vb: T, tb: Long) =
        if (ta != tb) (if (ta > tb) va to ta else vb to tb) else maxOf(va, vb) to ta
    val (read, readAt) = pick(a.read, a.readAt, b.read, b.readAt)
    val (notes, notesAt) = pick(a.notes, a.notesAt, b.notes, b.notesAt)
    …
    return SyncBook(…, read = read, readAt = readAt, notes = notes, notesAt = notesAt, deleted = a.deleted || b.deleted, …)
}

The merge does not depend on the order of its arguments, and merging the same version twice changes nothing, so devices and the server may merge in any order, as often as they like. A test checks both.

All of this changed the schema, from version 2 to version 3. Room can add a column with a constant default by itself, but every existing book needs its own uid, so the migration is written by hand, with SQL Room then validates against the exported 3.json:

val MIGRATION_2_3 = object : Migration(2, 3) {
    override suspend fun migrate(connection: SQLiteConnection) {
        // … add uid, createdAt, readAt, notesAt, deleted, deletedAt, dirty …
        connection.executeSQL("UPDATE `Book` SET uid = lower(hex(randomblob(16))), createdAt = id")
        // … the sample books get their fixed uids …
        connection.executeSQL("DROP INDEX IF EXISTS `index_Book_title`")
        connection.executeSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_Book_uid` ON `Book` (`uid`)")
    }
}

executeSQL here is the suspending version from androidx.sqlite.async, because the blocking execSQL does not exist in the browser, where the database is in a web worker. And the unique index on the title had to go: two devices may add the same title while offline, and a sync must be able to store both.


3. Seven Stories, Measured

A test, SyncScenarioTest, starts the real Ktor server inside the test process and two devices, each with the app’s own code: its own Room database, its repository and Syncer, Ktor’s client over HTTP, its own wall clock, and a switch for its network. Each story is told with that, and told again with a small model of the simpler design (NaiveSync, in the same test folder, not the app).

StoryA simpler designThis app
Both add a book offline“Dune” is gone everywhere: both were book 4both books on both devices
A deletes a book; B, offline, still has itthe book comes back on bothdeleted on both
Offline, A ticks a book, B writes a note on itthe tick is lostboth: read, with the note
B’s clock is an hour slow; B replies to A’s notethe reply loses, and vanishes from Bthe reply wins on both
Offline, both write the same book’s noteA’s note is lostA’s note is lost too, by rule
Offline, both add “Dune”the devices disagree: one copy eachboth copies on both devices
The answer to a sync is lost; A sends againtoggled twice: the book is unreadapplied once: read on B

Six stories end with nothing lost. The seventh, the same field changed on both devices while both are offline, ends with one of the two notes gone, and so does every last-writer-wins design. Keeping both would need the app to store both versions and ask the user, or to merge text; for a note on a reading list, the later one winning is a reasonable rule, and it is now an explicit one rather than an accident.

Passing tests only mean something if they fail without the fix. Two of the defences were removed on purpose, one at a time:

RemovedTests that failed
the hybrid clock (wall clock only)the clock-skew story, and the clock’s unit test
the per-field merge (the later row wins whole)the different-fields story, and two of the merge’s unit tests

With both back, the 6 tests of :sync and the 7 stories pass on the JVM, and the 6 merge tests also pass in Chrome as Wasm.


4. A Phone in Airplane Mode and a Browser

The stories run in one process. The demonstration runs on real clients: the server on this Mac, the Android app on an Android 16 emulator (which reaches the Mac as 10.0.2.2), and the web app in Chrome, opened with ?server=…. A script drives both through adb and puppeteer.

StepWhat happened
1. The phone syncs for the first time”Up to date”; the server has 3 books
2. Airplane mode on; the phone ticks Kotlin in Action and taps Syncticked; “Server not reachable · 1 change waiting”
3. Meanwhile, the browser writes a note on the same book and syncsthe server: note “written in the browser”, read: false
4. Airplane mode off; the phone taps Sync”Up to date”; on the phone: read true, the note there: true; the server: read true, the same note
5. A new browser opens the app and syncsthe book, with its note (“Kotlin in Action Aigner, Elizarov, Isakova, Jemerov · notes”)

The phone, offline: Kotlin in Action ticked, and the status line saying the server is not reachable and one change is waiting The phone, after it came back and synced: the book's screen shows the note written in the browser

The phone’s tick, made offline, and the browser’s note, made while the phone was away, ended up together, on the phone, on the server and in a browser opened afterwards. Two Android details were needed for it. Android refuses plain HTTP by default, so a network security configuration allows exactly one address, the emulator’s name for the computer it runs on; a real server would use HTTPS and need none. And the app needs the INTERNET permission, which it never needed before.

The app embedded in these posts does not sync: it has no server, so it shows no Sync button, and its Content Security Policy (from the previous post but one) would not let it reach one anyway.


5. The Reading List, Version 7

ModuleLinesWhat it is
sync57the format, the merge, the clock, the server’s store: shared by every target and the server
server19Ktor: one endpoint to sync, one to look
shared, commonMain426the app, written once: data, sync, state, navigation and UI
platform source sets35 to 57 eachas before, plus each platform’s Ktor engine and server address

The app compiles 91.8% (Android), 95.3% (iOS), 94.3% (desktop) and 89.1% (web) of its code from shared source; the server takes three quarters of its code from the same sync module. Sync cost 0.30 MB in the Android release APK (Ktor and OkHttp; 1.52 to 1.82 MB) and 0.12 MB in the compressed web download.

To run it yourself, download the project (reading-list-7.zip, 105 KB):

cd reading-list-7
./gradlew :sync:jvmTest :shared:jvmTest                  # the merge tests and the seven stories, against the real server
./gradlew :server:run                                    # the server, on port 8080
./gradlew :shared:run                                    # the desktop app, syncing with http://localhost:8080
./gradlew :androidApp:installDebug                       # on an emulator: syncs with the Mac as 10.0.2.2
./gradlew :shared:wasmJsBrowserDevelopmentRun            # then open the page with ?server=http://localhost:8080

6. Common Claims, Checked

ClaimVerdict
”Offline-first just means caching.”The device’s database is the truth for its screen, and changes made offline must travel later. Measured: a tick made in airplane mode reached the server and the other device.
”Auto-increment ids are fine as identities.”Two offline devices both created book 4, and one book was lost.
”Delete the row, and sync takes care of the rest.”The next sync from a device that still had it brought it back. Tombstones keep it deleted.
”Last writer wins is a safe default.”Per row, it lost a tick to an unrelated note. Per field, it kept both; it still drops one of two edits to the same field.
”Device clocks are good enough for ‘later’.”A clock an hour slow made a reply lose to the message it answered. A hybrid clock fixed it.
”Retrying a request is harmless.”Not for operations: “toggle” sent twice is undone. Sending state with timestamps made the retry a no-op.
”Sharing code with the server needs a separate language bridge.”The same 57-line Kotlin module merges on the phone, in the browser and on the server.

Summary

IdeaIn one line
Kotlin on both ends:sync (format, merge, clock) is common code; the server is 19 lines around it
IdentityA uid per book; fixed uids for the shared sample
DeletionTombstones, and a deletion wins
MergePer field, the later change wins; the same function on every device and the server
ClocksA hybrid clock: never behind what the device has seen
RetriesState, not operations: sending twice changes nothing
MeasuredSeven stories against the real server; a phone offline and a browser, merged

The chapter, in one table

PostWhat it measured
What KMP shares89.5% of Now in Android KMP’s code in commonMain; what each target ships
expect and actualthirteen mistakes, compiled for every target
Room everywhereone database on four platforms; SQLite in a browser worker
State and navigationwhat survives rotation, process death and a reload
Adaptive UItwo panes from 840 dp; where the web gets its fonts
The buildeach target’s cost; a 7-second check of every target
Offline sync (this post)seven ways two devices disagree, and a phone back from airplane mode
The pipeline, run (a bonus)CI on every push: the shared tests on the iOS simulator at last

The project: reading-list-7 (zip) (Kotlin 2.4.21, Ktor 3.6.0, Room 3.0.3, Compose Multiplatform 1.12.2, AGP 9.4.1, Gradle 9.8.1). The demonstration: sync_demo.py. The iOS code was compiled here and not linked or run; the bonus post links it and runs its tests in CI.


References

  • JetBrains. Ktor client and its engines; Ktor server.
  • Android Developers. Build an offline-first app and Network security configuration.
  • Kulkarni, S. S., Demirbas, M., Madappa, D., Avva, B., & Leone, M. (2014). Logical physical clocks. OPODIS. (The hybrid logical clock.)
  • Shapiro, M., Preguiça, N., Baquero, C., & Zawirski, M. (2011). Conflict-free replicated data types. SSS. (Why merges that ignore order and repetition can always agree.)
  • Kleppmann, M. (2017). Designing Data-Intensive Applications, chapter 5, on replication and last-writer-wins.
  • Lamport, L. (1978). Time, clocks, and the ordering of events in a distributed system. Communications of the ACM, 21(7).