How an Offline First App Syncs Only What Changed
How an Offline First App Syncs Only What Changed
Open an offline first app and content appears instantly, served from a local database while a background sync quietly reconciles that database with the server. Now in Android works exactly this way: every screen reads from Room, and a background worker refreshes that Room data behind the scenes. Most developers know this surface, a repository pulls from the network and writes to a local store. The deeper question is what the sync actually transfers over the wire. Refetching every topic and every article on every sync would burn bandwidth and battery, so a production offline first app instead asks the server a narrower question: what changed since the last time I synced?
In this article, you'll dive deep into the mechanism of versioned delta sync: how a client tracks a change list version per model, asks the server for only the rows that changed since that version, applies the resulting deletes and updates, and persists the new version. You'll trace the Synchronizer and Syncable contract, the changeListSync algorithm step by step, how ChangeListVersions is stored in Proto DataStore, how each repository plugs its own model into the generic algorithm, and how a SyncWorker on WorkManager drives the whole process without ever running two syncs at once.
The fundamental problem: Refetching the whole catalog on every sync
The simplest way to keep a local database fresh is to throw it away and rebuild it. A naive syncWith would download the entire remote collection and overwrite the local tables:
suspend fun syncWith(): Boolean {
val remoteTopics = network.getTopics()
topicDao.deleteAllTopics()
topicDao.upsertTopics(remoteTopics.map(NetworkTopic::asEntity))
return true
}
This works, but its cost scales with the total size of the catalog rather than with how much actually changed. Every sync transfers the full collection even when nothing moved. Deleting and reinserting unchanged rows invalidates every Room observer watching those tables. And to detect a server side deletion, you have to diff the full remote set against the full local set. As the catalog grows, the price of each sync grows with it, even when the daily change is a single edited article.
A version control system solves a nearly identical problem. Git does not re-download a repository to update it. It records where your local history stops, asks the remote for commits after that point, and fast forwards. Delta sync applies the same idea to app data: keep a cursor marking the newest server state you already hold, and each sync fetches only the rows past that cursor.
The cursor: One integer per model
The cursor in Now in Android is a plain integer, one per synced model, held in an immutable ChangeListVersions:
data class ChangeListVersions(
val topicVersion: Int = -1,
val newsResourceVersion: Int = -1,
)
Each field records the newest server version already applied for that model. The default of -1 means never synced. There are two independent cursors because topics and news resources have separate change logs on the server and advance independently. This value is the durable answer to the question "what do I already have?"
On the server side, every mutation to a model appends a row to that model's change log. The client sees each row as a NetworkChangeList:
@Serializable
data class NetworkChangeList(
val id: String, // id of the model that was changed
val changeListVersion: Int, // unique consecutive, monotonically increasing version
val isDelete: Boolean, // deleted vs updated (updates include creations)
)
Three fields describe a single change. id names which model changed, changeListVersion is a consecutive, monotonically increasing counter that plays the role of a git commit position, and isDelete distinguishes a deletion from a create or update. Notice that the version belongs to the change log row, not to the model. The client's cursor is simply the highest such version it has processed.
The contract: Synchronizer and Syncable
The design splits the responsibilities across two interfaces. A repository knows how to reconcile its own tables with the network, but not where the cursor lives. Something else owns cursor storage, but not the model specifics. Syncable is the marker every repository implements:
/**
* Marks a class as synchronized with a remote source. Syncing must not be performed
* concurrently, and it is the [Synchronizer]'s responsibility to ensure this.
*/
interface Syncable {
/** Synchronizes the local database backing the repository with the network.
* Returns if the sync was successful or not. */
suspend fun syncWith(synchronizer: Synchronizer): Boolean
}
A single method that returns a success or failure boolean rather than throwing. The interface KDoc also states that syncing must never run concurrently, and it assigns that duty to the Synchronizer rather than to this interface. Here the Synchronizer is the SyncWorker, whose uniqueness WorkManager enforces, as the last section shows.
The Synchronizer owns the cursor. It reads and writes the ChangeListVersions and offers a small piece of sugar so a repository can trigger its own sync without threading itself through the call:
interface Synchronizer {
suspend fun getChangeListVersions(): ChangeListVersions
suspend fun updateChangeListVersions(update: ChangeListVersions.() -> ChangeListVersions)
/** Syntactic sugar to call [Syncable.syncWith] while omitting the synchronizer argument */
suspend fun Syncable.sync() = this@sync.syncWith(this@Synchronizer)
}
getChangeListVersions reads the current cursors, and updateChangeListVersions takes a lambda that transforms the current versions into new ones, a read then update shape that keeps the mutation atomic at the storage layer. The Syncable.sync() extension is declared inside Synchronizer, so within a synchronizer, repository.sync() resolves to repository.syncWith(this). That is why the worker later writes topicRepository.sync() with no arguments.
The algorithm: changeListSync step by step
With the cursor and the contract in place, the delta algorithm itself lives in a single extension on Synchronizer. It is parameterized by five lambdas so that every repository reuses the same control flow and supplies only the model specific pieces. Start with the signature:
suspend fun Synchronizer.changeListSync(
versionReader: (ChangeListVersions) -> Int,
changeListFetcher: suspend (Int) -> List<NetworkChangeList>,
versionUpdater: ChangeListVersions.(Int) -> ChangeListVersions,
modelDeleter: suspend (List<String>) -> Unit,
modelUpdater: suspend (List<String>) -> Unit,
) = suspendRunCatching {
The five lambdas map cleanly onto the five things any model needs to do:
- versionReader: pulls this model's
Intout of the sharedChangeListVersions. - changeListFetcher: given the current version, returns the change log rows after it.
- versionUpdater: given a new version, returns an updated
ChangeListVersions. - modelDeleter: applies deletions by id.
- modelUpdater: applies creations and updates by id.
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor