<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Teimuraz</title>
    <description>The latest articles on DEV Community by Teimuraz (@teimuraz).</description>
    <link>https://dev.to/teimuraz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F58379%2F11a06d79-f823-45f3-a7dd-9003ea43de53.jpg</url>
      <title>DEV Community: Teimuraz</title>
      <link>https://dev.to/teimuraz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/teimuraz"/>
    <language>en</language>
    <item>
      <title>I built an offline-first sync engine with event sourcing, in Rust shared between mobile and backend</title>
      <dc:creator>Teimuraz</dc:creator>
      <pubDate>Sat, 18 Jul 2026 23:05:17 +0000</pubDate>
      <link>https://dev.to/teimuraz/i-built-an-offline-first-sync-engine-with-event-sourcing-in-rust-shared-between-mobile-and-backend-18a7</link>
      <guid>https://dev.to/teimuraz/i-built-an-offline-first-sync-engine-with-event-sourcing-in-rust-shared-between-mobile-and-backend-18a7</guid>
      <description>&lt;p&gt;&lt;em&gt;A small write-up about something I built mostly for fun — and it works. Code: &lt;a href="https://github.com/teimuraz/rust-mobile-offline-sync" rel="noopener noreferrer"&gt;github.com/teimuraz/rust-mobile-offline-sync&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;First, the setup — because it explains all the Rust in this post. The app I built this for is &lt;a href="https://trainvision.ai" rel="noopener noreferrer"&gt;TrainVision&lt;/a&gt;, a mobile-first platform for collecting machine-learning training data out in the real world (where reliable connectivity is often exactly what you don't have). Its &lt;strong&gt;core is written in Rust and shared across iOS and Android&lt;/strong&gt; via &lt;a href="https://mozilla.github.io/uniffi-rs/" rel="noopener noreferrer"&gt;UniFFI&lt;/a&gt;: the models, the sync, the business rules are written &lt;em&gt;once&lt;/em&gt; in Rust and compiled into a native library that both platforms call through generated Swift and Kotlin bindings. The &lt;strong&gt;backend is Rust too&lt;/strong&gt;, so the same code runs there. Storage is &lt;strong&gt;SQLite on the device&lt;/strong&gt; and &lt;strong&gt;Postgres on the backend&lt;/strong&gt;. That shared-Rust core is half of what this post is about, event sourcing is the other half — and the two turn out to fit together beautifully for offline sync.&lt;/p&gt;

&lt;p&gt;Now the actual problem. I needed an app that works with no internet. Not "degrades gracefully" — genuinely works: you're in a field, a factory, a basement, you capture data all day, and it syncs whenever a connection comes back. Sometimes there's good signal, often it's weak, sometimes there's none at all — the point of offline-&lt;em&gt;first&lt;/em&gt; is that the app never assumes the network is there. Connectivity is a nice bonus when it shows up, not something the app leans on.&lt;/p&gt;

&lt;p&gt;I looked for something existing first — Couchbase (Lite + Sync Gateway), ObjectBox, a few others. I got furthest with Couchbase, but couldn't get it to click. The moment I wanted to control how conflicts resolved or shape sync around my own domain, I felt like I was fighting the framework instead of using it.&lt;/p&gt;

&lt;p&gt;I don't recommend "just build your own sync engine" as career advice. But I was curious, it looked fun, and I wanted full control. So I started sketching how I'd do it myself — and it hit me: &lt;strong&gt;event sourcing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It wasn't a bolt from the blue. I'd played with event sourcing before, in a completely different context — a classic event-sourced &lt;em&gt;backend&lt;/em&gt; architecture, nothing to do with offline or mobile. I'd found it a genuinely interesting way to build a system: state as a fold over an append-only log of domain events. So when I started thinking about how multiple offline devices could ever reconcile, I remembered it — and realized it fits this problem really well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why event sourcing makes offline sync easy
&lt;/h2&gt;

&lt;p&gt;The hard part of offline-first isn't storing data locally. It's &lt;em&gt;merging&lt;/em&gt;. Multiple devices edit the same thing while offline; someone deletes a record another person just updated; the same change syncs twice on a flaky connection. If each device only keeps the &lt;strong&gt;current state&lt;/strong&gt;, merging means shipping whole entities back and forth, comparing them field by field, and guessing what changed — and every one of those guesses is a chance to get it wrong. I wanted something stupidly simple instead.&lt;/p&gt;

&lt;p&gt;Event sourcing flips it. You don't store the item. You store the &lt;strong&gt;events&lt;/strong&gt; that produced it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ItemCreated      { name: "Wrench", quantity: 10 }
QuantityChanged  { quantity: 8 }
NoteChanged      { note: "left bin" }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The current item is just a &lt;em&gt;fold&lt;/em&gt; over those events — replay them in order and you get the current state. (That folded result has a name in event-sourcing circles: a &lt;strong&gt;projection&lt;/strong&gt;.) That one shift changes everything for sync, because events are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Append-only&lt;/strong&gt; — devices never overwrite each other, they only add. Sync becomes "ship the events the other side hasn't seen yet."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent to replay&lt;/strong&gt; — every event has an id, so receiving it twice is a no-op. A dropped connection mid-sync costs nothing but a retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orderable&lt;/strong&gt; — put every replica's events into one agreed order and they all fold to the same state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My entire entity contract is one small trait:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;trait&lt;/span&gt; &lt;span class="n"&gt;EventSourcedEntity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Evt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;EntId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EntityId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/// How one event changes the entity: given the current state, one event,&lt;/span&gt;
    &lt;span class="cd"&gt;/// and when it happened, produce the next state.&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;apply_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;modified_at_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;uncommitted_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EntityEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Evt&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;EntId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/// Rebuild by folding history (already in canonical order).&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;build_from_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EventDescriptor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;EntId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="py"&gt;.replica_time_ms&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="nf"&gt;.apply_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="py"&gt;.payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;entity&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/// Record a new local change: remember it as uncommitted, and apply it now so&lt;/span&gt;
    &lt;span class="cd"&gt;/// the UI updates immediately — even fully offline.&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;new_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EntityEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Evt&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.uncommitted_events&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.apply_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="py"&gt;.event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="py"&gt;.modified_at_ms&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A domain type implements &lt;code&gt;apply_event&lt;/code&gt; &lt;strong&gt;once&lt;/strong&gt; — "given current me and this event, here's the new me" — and gets local optimistic updates, history rebuild, and sync for free. In the demo repo that domain type is a deliberately boring inventory &lt;code&gt;Item&lt;/code&gt;, and its &lt;code&gt;apply_event&lt;/code&gt; is the whole thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;apply_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ItemEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;modified_at_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.deleted&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// deletion is terminal — this line is the entire "delete wins" rule&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.last_modified_ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modified_at_ms&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;ItemEvent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Created&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.quantity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nn"&gt;ItemEvent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Renamed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;           &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nn"&gt;ItemEvent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;QuantityChanged&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.quantity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nn"&gt;ItemEvent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;NoteChanged&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;       &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nn"&gt;ItemEvent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Deleted&lt;/span&gt;                    &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  "Why not just sync the database change log?"
&lt;/h2&gt;

&lt;p&gt;Fair question — the obvious alternative is row-level change-data-capture: ship the database's change log (WAL / logical replication / a &lt;code&gt;changes&lt;/code&gt; table) and replay it on the other side. Plenty of sync systems work exactly this way.&lt;/p&gt;

&lt;p&gt;Honestly, I don't have a rigorous answer for why I didn't. It came down to this: &lt;strong&gt;I didn't want to sync rows, I wanted to sync intent.&lt;/strong&gt; A row change tells you &lt;code&gt;quantity: 10 → 8&lt;/code&gt;. A domain event tells you &lt;em&gt;&lt;code&gt;QuantityChanged { 8 }&lt;/code&gt;&lt;/em&gt; — the actual thing that happened, with the meaning attached. That difference ended up mattering for two things I cared about: resolving conflicts (a &lt;code&gt;Deleted&lt;/code&gt; event can &lt;em&gt;mean&lt;/em&gt; "this beats a concurrent edit", where two raw row-writes just look like a clash), and — the bigger one — &lt;strong&gt;authorization&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorization: who can push and pull what
&lt;/h2&gt;

&lt;p&gt;This turned out to be a big one for me. In my app, a user is &lt;strong&gt;not&lt;/strong&gt; allowed to sync everything. They can read and write only a slice of the data: some &lt;strong&gt;common/shared data&lt;/strong&gt; everyone in their scope sees, plus &lt;strong&gt;their own data&lt;/strong&gt; — and nothing else. So when a device pushes a batch of events, the server has to check, for each event: &lt;em&gt;is this user allowed to write this?&lt;/em&gt; And when it pulls: &lt;em&gt;which data is this user even allowed to see?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Domain events gave me a natural place to enforce that. Every event knows which entity it targets and who authored it, so the server can authorize each pushed event against the user's permitted scope, and filter the pull side down to exactly the slices they're allowed to read. Maybe you can do this just as well with a row-log approach — I genuinely didn't dig into it. This just felt like a clean seam to me, so I went with it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(The demo repo keeps this part deliberately simple — the point there is the sync mechanics — but scoped, per-event authorization is a first-class concern in the real app, not an afterthought.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part: ordering across devices
&lt;/h2&gt;

&lt;p&gt;Here's where I stop pretending it's perfect. To fold events deterministically you need a &lt;strong&gt;total order across devices&lt;/strong&gt;, and there is no global clock. My rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Order by the event's &lt;strong&gt;replica (device) time&lt;/strong&gt; first. Device clocks drift, and can even be set wrong on purpose — &lt;em&gt;this is a fact we accept.&lt;/em&gt; If two events land on the exact same timestamp, a per-device counter breaks the tie; if even that matches, the event id does.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When I poked at how some established apps handle this — Apple Notes, for one — they seem to lean on device local time in much the same way. That made me feel a lot better about not over-thinking it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;EventDescriptor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EntId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;replica_event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// unique across all replicas&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;replica_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// which device/server authored it&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;replica_time_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// that replica's wall-clock at creation&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;replica_write_offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// per-replica tie-breaker&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;// the domain event itself&lt;/span&gt;
    &lt;span class="c1"&gt;// ... entity id, server-assigned offset/time&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// the canonical fold order, applied on every replica before folding:&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;order_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;EventDescriptor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="py"&gt;.replica_time_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="py"&gt;.replica_write_offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="py"&gt;.replica_event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the whole rule is: &lt;strong&gt;the most recent change by the device clock wins&lt;/strong&gt;, and everything gets applied in that order. It's not a clever academic algorithm — I deliberately kept it dead simple, predictable, and easy to reason about. The one extra rule I add: &lt;strong&gt;deletion wins over concurrent edits&lt;/strong&gt; — and as you saw above, that's not special machinery, it's a single &lt;code&gt;if self.deleted { return; }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sync itself is then almost boring, which is the goal. The server stamps each event with a monotonic &lt;code&gt;ServerOffset&lt;/code&gt;. A device remembers the highest offset it has pulled and asks: &lt;em&gt;"give me everything after this."&lt;/em&gt; It folds the new events into its projections, ships its own un-synced events up, done. In the real app that ask-and-ship is a gRPC round-trip to the backend; in the demo repo it's plain HTTP — same logic, different transport. No diffing, no merge UI, no magic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;trait&lt;/span&gt; &lt;span class="n"&gt;EventLog&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EntId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppendEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EntId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EventDescriptor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EntId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;events_of_entity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;EntId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EventDescriptor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EntId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;events_after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ServerOffset&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;EventsPage&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;E&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EntId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part I'm actually proud of: one codebase, three platforms
&lt;/h2&gt;

&lt;p&gt;I write Rust for the backend. The mobile app is iOS (Swift) and Android (Kotlin). And it isn't just the sync plumbing that's shared — the &lt;strong&gt;domain models and their events are shared too.&lt;/strong&gt; The &lt;code&gt;Item&lt;/code&gt; entity, the &lt;code&gt;ItemEvent&lt;/code&gt; enum that spells out every change that can happen to it, the fold that turns those events into current state, the ordering, the codecs both sides must agree on byte-for-byte — all of it is the &lt;strong&gt;exact same Rust code&lt;/strong&gt; running in all three places. I define an entity and its events &lt;em&gt;once&lt;/em&gt;, and the backend, iOS, and Android already agree, down to the byte, on what they are.&lt;/p&gt;

&lt;p&gt;The device and the server implement the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;EventLog&lt;/code&gt; trait. On the backend it's backed by &lt;strong&gt;Postgres&lt;/strong&gt;; on the phone by &lt;strong&gt;SQLite&lt;/strong&gt;. Same interface, same folding logic, same conflict rules — because it is &lt;em&gt;literally the same functions&lt;/em&gt; on top of two different storage backends. On mobile the crate is compiled to a native library and exposed to Swift/Kotlin through UniFFI, so iOS and Android share it too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it actually work? Here's it running
&lt;/h2&gt;

&lt;p&gt;The repo ships the real thing, end to end: an axum backend with its own event log, and a SwiftUI app driving the shared Rust SDK — local edits, background sync job, and an &lt;strong&gt;Online toggle&lt;/strong&gt; that simulates losing the network.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo run &lt;span class="nt"&gt;-p&lt;/span&gt; backend &lt;span class="nt"&gt;--bin&lt;/span&gt; server   &lt;span class="c"&gt;# the backend (in-memory; Postgres in reality)&lt;/span&gt;
./mobile/ios/build_rust.sh          &lt;span class="c"&gt;# compile the Rust SDK for iOS + Swift bindings&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run the app in &lt;strong&gt;two simulators side by side&lt;/strong&gt; — each install gets its own replica id, so they're two independent devices syncing through the real server over HTTP:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add items on device A → they show up on device B a few seconds later.&lt;/li&gt;
&lt;li&gt;Toggle device B offline, edit the &lt;em&gt;same&lt;/em&gt; item on both, bring B back online → both devices converge (later device clock wins), and flipping back online triggers an immediate sync.&lt;/li&gt;
&lt;li&gt;Delete an item on A while B edits it offline → the deletion wins everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And you can watch it from the server's side while you do it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl localhost:4000/items       &lt;span class="c"&gt;# current state, folded server-side from the events&lt;/span&gt;
curl localhost:4000/events/all  &lt;span class="c"&gt;# the raw event log with all the provenance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fold, two-replica convergence, and deletion-wins are also pinned down as unit tests (&lt;code&gt;cargo test -p shared&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  It's simple — and it works
&lt;/h2&gt;

&lt;p&gt;I'll be clear-eyed: this is a small, deliberately simple design, not a big framework, and there are edge cases I've chosen to accept rather than solve. But the clock-based ordering has held up fine in practice — and, as I mentioned, it seems to be roughly how apps like Apple Notes handle it too. It survives real offline use, and I understand every line — which, after fighting a black-box sync framework, is worth a lot.&lt;/p&gt;

&lt;p&gt;If it's useful to anyone, I pulled the core idea into a tiny, generic demo — an offline-first inventory of &lt;code&gt;Item&lt;/code&gt;s — with the shared Rust crate, the sync engine, and the runnable simulation above:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;→ &lt;a href="https://github.com/teimuraz/rust-mobile-offline-sync" rel="noopener noreferrer"&gt;github.com/teimuraz/rust-mobile-offline-sync&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's deliberately small. The point isn't the app, it's the shape: one Rust event model, folded on the server and on the phone, synced by a plain offset cursor. If you need offline-first and the existing tools feel like too much, maybe event sourcing is a good fit for you too.&lt;/p&gt;

&lt;p&gt;This is the engine behind &lt;a href="https://trainvision.ai" rel="noopener noreferrer"&gt;TrainVision&lt;/a&gt; — if you're collecting training data in the field and want it to just work offline, that's what it's for.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with Rust + UniFFI. Questions and roasts welcome — in the comments or on &lt;a href="https://github.com/teimuraz/rust-mobile-offline-sync/issues" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— Teimuraz, building &lt;a href="https://trainvision.ai" rel="noopener noreferrer"&gt;TrainVision&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>mobile</category>
      <category>offlinefirst</category>
      <category>eventsourcing</category>
    </item>
  </channel>
</rss>
