Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Offline should be the norm: building local-firs...

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.

Offline should be the norm: building local-first apps with CRDTs & Kotlin Multiplatform

In 2026, a loading spinner in the subway isn’t “bad connectivity”. It’s a cloud-first app showing its limits. This talk is about offline-first architecture: write locally first, and treat sync as a background process, not a user-blocking gate. The hard part is concurrency: Alice edits a to-do offline while Bob checks it off. Who wins? Definitely not “last-write-wins”. We will unpack CRDTs, a data structure designed to merge changes without conflicts and converge reliably. Then we go hands-on with Kotlin Multiplatform by designing a practical local-first architecture, local persistence, a sync engine built around deltas, and the real-world challenges (tombstones, pruning, UI “glitches”). The goal: one robust, shared sync core for Android, iOS, the Web, and apps that stays trustworthy even when the network isn’t.

Avatar for Renaud MATHIEU

Renaud MATHIEU

April 09, 2026

More Decks by Renaud MATHIEU

Other Decks in Programming

Transcript

  1. O ffl ine should be the norm building local-first apps

    with CRDTs & Kotlin Multiplatform @renaudmathieu.com Android Makers by droidcon 2026
  2. What is displayed on the app? • Please connect to

    the internet and retry? • « Baguette Tradition » or « Gluten free Tradition » ? • Are all the items checked or unchecked?
  3. Why O ff l ine-First? Users encounter network lapses daily

    because of: • Limited bandwidth in areas with poor coverage. • Interruptions in elevators or tunnels • Infrequent access: Wi-Fi-only tablets, rural areas, travel.
  4. What is an O ff l ine-First Application? An offline-first

    app can operate without internet, with or without some features. Specifically, it must fulfill three criteria: • Remain usable without a reliable network connection. • Present local data immediately, without waiting for a first network call. • Retrieve data in a battery and data-conscious way (e.g., only sync on Wi-Fi or while charging).
  5. Th e Central Role of the Data Layer All offline-first

    thinking starts in the data layer of the app architecture. This layer provides access to application data and business logic. The two fundamental operations on data are: 1. Reads: retrieve data to display to the user. 2. Writes: persist user input to retrieve later.
  6. Th e Two Data Sources An offline-first app has at

    minimum two data sources for each repository that uses network resources: • The local data source • The network data source
  7. Th e Local Source: Th e Source of Truth The

    local source is the source of truth (Single Source of Truth). It must be the exclusive source of all data read by the upper layers. This guarantees data consistency across connection states. Common local persistence means: • Structured data: relational databases like Room. • Unstructured data: Protocol Buffers with DataStore. • Simple fi les: JSON, XML, etc.
  8. Th e Network Source: Th e Real State The network

    source represents the real state of the application on the server side. The local source is at best synchronized with it. It can be behind in both directions: • Network ahead: the app must update itself when it comes back online. • Local ahead: the network must be updated when connectivity returns. The domain and UI layers must never communicate directly with the network source. That is the responsibility of the Repository.
  9. Separate models per layer Each data source often needs its

    own representation. We therefore define three models: @Serializable data class NetworkAuthor( val id: String, val name: String, val imageUrl: String, val twitter: String, val mediumPage: String, val bio: String, )
  10. Separate models per layer Each data source often needs its

    own representation. We therefore define three models: @Entity(tableName = "authors") data class AuthorEntity( @PrimaryKey val id: String, val name: String, @ColumnInfo(name = "image_url") val imageUrl: String, @ColumnInfo(defaultValue = "") val twitter: String, @ColumnInfo(name = "medium_page", defaultValue = "") val mediumPage: String, @ColumnInfo(defaultValue = "") val bio: String, )
  11. Separate models per layer Each data source often needs its

    own representation. We therefore define three models: data class Author( val id: String, val name: String, val imageUrl: String, val twitter: String, val mediumPage: String, val bio: String, )
  12. Mapping Functions Conversions between models are done via extension functions:

    // Network -> Local fun NetworkAuthor.asEntity() = AuthorEntity( id = id, name = name, imageUrl = imageUrl, twitter = twitter, mediumPage = mediumPage, bio = bio, ) // Local -> External fun AuthorEntity.asExternalModel() = Author( id = id, name = name, imageUrl = imageUrl, twitter = twitter, mediumPage = mediumPage, bio = bio, )
  13. Why Th ree Models? This separation protects the external layers

    from minor changes in the local or network sources that do not fundamentally change the app's behavior. For example, a column rename in the database, or a change in the JSON format, does not affect the UI.
  14. Two Synchronization Models When an offline-first app regains connectivity, it

    must reconcile its local data with the network’s. This process is called synchronization.
  15. Pull-based Synchronization The app contacts the network to read the

    latest data on demand. The common heuristic is navigation-based: the app only fetches data just before presenting it. ✅ Relatively easy to implement ✅ Unnecessary data is never fetched ❌ Prone to high data consumption (unnecessary re-fetches) ❌ Not well suited for relational data
  16. Push-based Synchronization The local source attempts to replicate the network

    source as closely as possible. It proactively fetches an appropriate volume of data on first launch, then relies on server notifications to know when data is stale. ✅ The app can stay offline indefinitely ✅ Minimal data consumption (only fetches changes) ✅ Works well for relational data ❌ Versioning for conflict resolution is non-trivial ❌ The network source must support synchronization
  17. Reads via Observable Types The key pattern: read APIs expose

    Flow<T>. The repository reads directly from the local source and can only notify its consumers of changes by first updating the local source. class OfflineFirstTopicsRepository( private val topicDao: TopicDao, private val network: NiaNetworkDataSource, ) : TopicsRepository { override fun getTopicsStream(): Flow<List<Topic >> = topicDao .getTopicEntitiesStream() .map { it.map(TopicEntity :: asExternalModel) } }
  18. Data Flow Reads in an offline-first repository must read directly

    from the local source. Updates are written to the local source first, which then notifies its consumers because it is observable. • The ViewModel collects the Flow from the repository. • The repository exposes the Flow from the local DAO. • When the network updates the local DB, the Flow emits automatically. • The UI updates accordingly.
  19. Error Handling: Local Source Read errors from the local source

    are rare but possible. We protect against them with the catch operator on Flow: class AuthorViewModel( authorsRepository: AuthorsRepository, ) : ViewModel() { private val authorId: String = ... private val authorStream: Flow<Author> = authorsRepository .getAuthorStream(id = authorId) .catch { emit(Author.empty()) } }
  20. Error Handling: Network Source If errors occur when reading from

    the network, the app should use a retry heuristic. Strategy 1: Exponential Backoff The app attempts to read from the network at increasing intervals until success or a stop condition is reached.
  21. Error Handling: Network Source If errors occur when reading from

    the network, the app should use a retry heuristic. Strategy 2: Network Connectivity Monitoring Read requests are queued until the app is certain it can connect. Once the connection is established, the queue is drained, data is read, and the local source is updated.
  22. Error Handling: Network Source Combining Both Strategies In practice, both

    approaches are often combined: 1. Network monitoring to trigger synchronization at the right moment. 2. Exponential backoff to handle temporary failures during synchronization.
  23. O ffl ine Write Strategies If the recommended method for

    reading data is to use observable types, the equivalent for write APIs is to use asynchronous APIs, such as suspend functions in Kotlin. This avoids blocking the UI thread and simplifies error handling, since offline-first writes can fail when crossing a network boundary. interface UserDataRepository { suspend fun updateNewsResourceBookmark( newsResourceId: String, bookmarked: Boolean ) }
  24. Th e Th ree Write Strategies Strategy 1: Online-Only Writing

    The app attempts to write data via the network. On success, it updates the local source. On failure, it throws an exception. Use Case Transactions that must happen online and in near real time. Example: a bank transfer. Handling Failure • Hide the write UI if the app requires internet to write (disable buttons). • Popup message or transient prompt to inform the user they are offline.
  25. Th e Th ree Write Strategies Strategy 2: Queued Writes

    The object to write is inserted into a queue. The queue is drained with exponential backoff when the app comes back online. Use Case Good choice when: • It is not essential that the data is written to the network. • The transaction is not time-sensitive. • It is not essential to inform the user of failure.
  26. Th e Th ree Write Strategies Strategy 3: Lazy Writes

    The app writes to the local source first, then queues the write to notify the network as soon as possible. This is the most complex strategy, as it can cause conflicts between local and network sources when the app comes back online. Use Case Good choice when the data is critical for the app. Example: in an offline-first to-do list app, tasks added offline must be stored locally to avoid data loss. ⚠ Writing data in offline-first apps often requires more thought than reading, due to potential conflicts. An offline-first app does not need to be able to write offline to be considered offline- first.
  27. Con fl ict Resolution If the app writes data locally

    when offline and that data is misaligned with the network source, a conflict arises.
  28. Con fl ict Resolution Last Write Wins Approach This is

    the most common approach for mobile applications: • Each device attaches a timestamp to written data. • The network source receives data from the different devices. • It rejects data older than its current state. • It accepts more recent data.
  29. Mathematical Foundations of CRDTs CRDTs (Conflict-free Replicated Data Types) rest

    on precise algebraic properties that guarantee convergence by construction. Before writing any code, it is essential to understand the underlying mathematics.
  30. Partial Order A partial order is a binary relation on

    a set that satisfies three properties: • Re fl exivity: • Antisymmetry: • Transitivity: ≤ S ∀x ∈ S, x ≤ x ∀x, y ∈ S, (x ≤ y ∧ y ≤ x) ⇒ x = y ∀x, y, z ∈ S, (x ≤ y ∧ y ≤ z) ⇒ x ≤ z
  31. Join-Semilattice A join-semilattice is a partially ordered set in which

    every pair of elements has a least upper bound (also called join), denoted . De fi nition of the Least Upper Bound is the smallest element such that: • • • (S, ≤ ) x, y x ⊔ y x ⊔ y z x ≤ z y ≤ z ∀w, (x ≤ w ∧ y ≤ w) ⇒ z ≤ w
  32. Join-Semilattice Properties of the Join Operation (merge) The operation must

    be: • Commutative: , the order in which states are received does not matter. • Associative: You can merge in groups in any order. • Idempotent: , receiving the same state twice changes nothing. ⊔ ⊔ x ⊔ y = y ⊔ x (x ⊔ y) ⊔ z = x ⊔ (y ⊔ z) x ⊔ x = x
  33. Monotonicity of Updates For the system to work, updates must

    be monotone: each update only « moves up » in the partial order. Formally, if is the current state and an update operation: This means you can never go back. Information only grows. This is why elements like tombstones (deletion markers) are necessary: you do not « delete », you add the information that an element was deleted. s f s ≤ f(s)
  34. Strong Eventual Consistency The central property of CRDTs is Strong

    Eventual Consistency (SEC). If: • Two replicas have received the same set of updates (in any order), • Then they are in the same state. This is stronger than classic eventual consistency, because no manual conflict resolution is needed. Convergence is automatic and guaranteed by the algebraic structure.
  35. Visual Summary Here is the complete logical chain: 1. Partial

    order, models incomparable concurrent states. 2. Semilattice, guarantees that a least upper bound (merge) always exists. 3. Commutativity + Associativity + Idempotence, invariance to reordering and duplication. 4. Monotonicity, updates only "move up". 5. Strong convergence, same updates = same final state, guaranteed.
  36. State-based CRDTs (CvRDTs) Principle CvRDTs ("Convergent Replicated Data Types") send

    their complete state to other replicas. The receiving replica merges the received state with its local state via the merge function.
  37. State-based CRDTs (CvRDTs) Requirements • The set of states must

    form a monotone join-semilattice. • The merge function computes the least upper bound (LUB). • Updates must be monotone (the state only grows).
  38. Delta-state CRDTs Principle Delta-CRDTs are an optimization of CvRDTs. Instead

    of sending the full state, only the recent changes (deltas) are sent. This combines the advantages of both approaches: • Conceptual simplicity of CvRDTs (semilattice, merge). • Network efficiency of CmRDTs (only transmit what changed).
  39. Delta-state CRDTs How It Works 1. Each update produces a

    delta (state fragment). 2. Deltas are accumulated in a buffer. 3. Periodically, the buffer is sent to other replicas. 4. The receiver merges the delta with its local state via the same merge.
  40. Catalogue of Common CRDTs G-Counter a grow-only counter. Each replica

    counts its own increments, and the total is the sum of all replicas. Use it for things like view counts or likes. G-Set a grow-only set. You can add elements, but you can never remove them. Sounds limiting, but it's perfect for append-only logs or event streams. OR-Set Add and remove, with add-wins on conflict. LWW-Register Last write wins. Use for simple fields
  41. Why Kotlin Multiplatform? The synchronization logic (CRDTs, merge, delta engine)

    is the most complex and critical part of the app. Duplicating it for each platform would be risky. KMP allows you to: • Share the sync engine between Android, iOS, and the Web. • Test the convergence logic only once. • Isolate the complexity inside a commonMain module.
  42. Th e Delta Sync Engine Synchronization Flow • The user

    makes a local modification. • The CRDT produces a delta (minimal state fragment). • The delta is added to the pending delta buffer. • When the network is available, the buffer is sent to the server. • The server merges the delta into its state. • The server broadcasts the deltas to other clients. • Clients receive and merge the remote deltas.
  43. Real-World Challenges Tombstones Problem: To support deletion in an OR-Set,

    each deleted element leaves a tombstone. Over time, tombstones accumulate and consume memory and bandwidth. Solution: When we're sure all replicas have acknowledged a tombstone, it can be safely removed (pruning). Vector clocks help determine this: a tombstone is eligible for deletion if its timestamp is less than or equal to the local vector clock, indicating all replicas have processed it.
  44. Real-World Challenges UI Glitches Problem: When a remote merge arrives,

    the UI may briefly display inconsistent intermediate states. For example, a deleted element may reappear for a fraction of a second before being removed. Solutions: A. Update batching: accumulate incoming deltas and apply them in a single merge before notifying the UI. B. DiffUtil / ListDiffer: use diff algorithms to compute the minimal changes to apply to the UI, avoiding flickering. C. Optimistic UI: show the expected result immediately and silently correct in the background if the merge produces a different result.
  45. Key Takeaways • Write locally fi rst, synchronize in the

    background. • CRDTs guarantee convergence by mathematical construction. • Delta-state CRDTs are the best tradeoff for mobile. • KMP allows sharing the critical logic across all platforms. • Real-world challenges (tombstones, pruning, UI glitches) have known solutions. • The result: a reliable app even when the network is not.
  46. References • Shapiro, Preguiça, Baquero, Zawirski, A comprehensive study of

    Convergent and Commutative Replicated Data Types (INRIA, 2011) • https://github.com/ljwagerfield/crdt • https://github.com/android/nowinandroid Th ank you