<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Yuri V — dev notes]]></title><description><![CDATA[Yuri V — dev notes]]></description><link>https://yurival.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 24 Oct 2026 08:39:57 GMT</lastBuildDate><atom:link href="https://yurival.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Offline-first sync in React Native + Firestore: pending state, tombstones and a pure merge]]></title><description><![CDATA[Lessons from an open-source generator logbook that has to work when there's no power and no internet]]></description><link>https://yurival.hashnode.dev/offline-first-sync-in-react-native-firestore-pending-state-tombstones-and-a-pure-merge</link><guid isPermaLink="true">https://yurival.hashnode.dev/offline-first-sync-in-react-native-firestore-pending-state-tombstones-and-a-pure-merge</guid><category><![CDATA[React Native]]></category><category><![CDATA[Firebase]]></category><category><![CDATA[offline first]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[YV]]></dc:creator><pubDate>Wed, 21 Oct 2026 10:28:28 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/652ced56680712612734f24e/fbe616b3-e359-44db-bce3-f8d85699e1f8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When the power goes out on a schedule, a generator runs every day — and it comes with a maintenance schedule: oil, filters and spark plugs, all by engine hours. Most portable generators have no hour meter, so I built Generator Tracker: a one-tap Start/Stop logbook with fuel tracking, maintenance reminders and monthly charts. The code is open source (MIT): <a href="https://github.com/yuri-val/GeneratorTracker">https://github.com/yuri-val/GeneratorTracker</a></p>
<p>This post isn't about the app itself, but about the part that turned out to be the most interesting engineering problem: sync for an app people use precisely when there's no connection.</p>
<h2>Stack</h2>
<ul>
<li><p>Expo SDK 57, React Native 0.86, React 19.2, TypeScript.</p>
</li>
<li><p>AsyncStorage as the local store; Firebase Auth + Firestore for optional sync.</p>
</li>
<li><p>iOS: native SwiftUI forms via <code>@expo/ui</code> and system tabs with Liquid Glass. Android: Material 3. Web stays on React Native Paper.</p>
</li>
</ul>
<h2>Principle 1: the phone is the source of truth</h2>
<p>During a blackout mobile internet is often down too. So every operation goes to local storage first, and the cloud is just a copy for the same user's other devices. The UI never waits for the network.</p>
<p>Every write to AsyncStorage goes through one FIFO mutex. Without it, two concurrent operations — say, a "Stop" tap and a cloud pull — read the same version of the data and overwrite each other's changes.</p>
<h2>Principle 2: state, not a queue</h2>
<p>The first version had a classic sync queue: every change was appended to <code>@sync_queue</code>, to be drained later. In practice the queue drifted away from the data, and changes that failed to upload three times were simply dropped — silently lost.</p>
<p>There's no queue now. Every created or edited record is stored with status <code>pending</code> and a monotonic <code>lastModified</code> set by the storage layer, not by the caller. Sync just takes everything that's <code>pending</code>. Deletes record a <strong>tombstone</strong> — even when the user isn't signed in — so the deletion eventually reaches the cloud and a pull can never resurrect the record. Old installs migrate automatically: queued deletes become tombstones, queued updates become <code>pending</code>.</p>
<h2>Sync: push, then pull</h2>
<p><code>performFullSync</code> is a push followed by a pull, never both at once:</p>
<ol>
<li><p><strong>Push:</strong> tombstones → cloud deletes (a generator with all its subcollections), then generators, then the children of generators that already exist in the cloud. Successful writes are marked synced only if the record wasn't edited while the request was in flight. Failures stay <code>pending</code> until the next sync.</p>
</li>
<li><p><strong>Pull:</strong> a complete snapshot via <code>getDocsFromServer</code> (child collections before generators), merged by a pure function.</p>
</li>
</ol>
<p>Every network call has a 30-second timeout — on a bad connection Firestore can hang for a very long time.</p>
<h2>Merge rules</h2>
<p>The most important decision was to make the merge a pure function with no I/O. The rules:</p>
<ul>
<li><p><strong>Last write wins</strong> on the client ISO <code>lastModified</code>. Firestore stores the client time unchanged (server time goes to a separate <code>serverUpdatedAt</code>), and documents are written with a full <code>set</code> so cleared fields propagate too.</p>
</li>
<li><p><strong>Equal timestamps</strong> mean the remote copy <em>is</em> this version, and the local record becomes synced.</p>
</li>
<li><p><strong>A local tombstone always wins</strong> — a record deleted on this device never comes back.</p>
</li>
<li><p><strong>A remote deletion wins too</strong>, but absence from a snapshot only deletes records known to be in <em>this</em> account's cloud (<code>userId === uid</code> and synced before the snapshot was taken). Otherwise a brand-new local record, or another account's data, would vanish.</p>
</li>
<li><p><strong>Orphans</strong> — child records without a generator, left behind by older versions — are not stored.</p>
</li>
</ul>
<p>This is what the "known to be in the cloud" check looks like (<code>src/services/syncMerge.ts</code>):</p>
<pre><code class="language-ts">const isConfirmedInCloud = (record: SyncEntity, ctx: MergeContext): boolean =&gt; {
  if (record.userId !== ctx.uid) return false;
  if (record.syncedAt) return compareTimestamps(record.syncedAt, ctx.snapshotTime) &lt; 0;
  return record.syncStatus === 'synced';
};
</code></pre>
<p>Realtime <code>onSnapshot</code> listeners apply added/modified/removed changes, and the first <em>server</em> snapshot of each listener reconciles deletions made while offline. Cache-only snapshots never prune anything — the sneakiest bug of the lot: after a reinstall the cache is empty, and "missing from the cache" doesn't mean "deleted".</p>
<h2>Testing it</h2>
<ul>
<li><p>The pure merge is covered by Jest unit tests — local/remote/tombstone/other-account/orphan scenarios without mocking the network; the push/pull orchestration has its own tests with a mocked Firestore.</p>
</li>
<li><p>End-to-end Playwright tests against the web build, plus a suite that runs against the Firebase Auth/Firestore emulators so sync is exercised without production data (emulator mode forces a demo project).</p>
</li>
<li><p>Firestore security rules tests on the emulator.</p>
</li>
<li><p>Maestro flows for the native UI on iOS and Android.</p>
</li>
</ul>
<h2>A few gotchas</h2>
<ul>
<li><p><strong>Hermes has no</strong> <code>Intl.PluralRules</code> — plural forms ("1 hour / 2 hours", and Ukrainian's three forms) had to be written by hand, even though everything worked in the browser.</p>
</li>
<li><p><strong>iOS 26:</strong> you can't attach the tab bar accessory conditionally — SwiftUI rebuilds the TabView and the app crashes. The "live" bar of running generators is a plain footer above the tab bar instead.</p>
</li>
<li><p><strong>Nested</strong> <code>Pressable</code><strong>s</strong> render as a <code>&lt;button&gt;</code> inside a <code>&lt;button&gt;</code> on web — invalid HTML and odd clicks.</p>
</li>
<li><p>Two editions (a paid one without ads and a free one with limits) come from one codebase: <code>app.config.js</code> picks the edition at build time, and screens only ask a <code>useEntitlements()</code> hook.</p>
</li>
</ul>
<h2>Try it</h2>
<ul>
<li><p><strong>iPhone / iPad</strong> — free on the App Store: <a href="https://apps.apple.com/app/id6819560707">https://apps.apple.com/app/id6819560707</a> (new versions first in the public TestFlight beta: <a href="https://testflight.apple.com/join/wZcGdJar">https://testflight.apple.com/join/wZcGdJar</a>)</p>
</li>
<li><p><strong>Android</strong> — the free Lite edition is in closed testing (Google needs 12 testers for 14 days, so every tester helps): join <a href="https://groups.google.com/g/generator-tracker-lite">https://groups.google.com/g/generator-tracker-lite</a>, then opt in at <a href="https://play.google.com/apps/testing/com.yurival.GeneratorTrackerLite">https://play.google.com/apps/testing/com.yurival.GeneratorTrackerLite</a></p>
</li>
<li><p><strong>Code and issues</strong> — <a href="https://github.com/yuri-val/GeneratorTracker">https://github.com/yuri-val/GeneratorTracker</a></p>
</li>
</ul>
<p>How do you handle offline sync in your apps — CRDTs, your own queue, or something like WatermelonDB or PowerSync?</p>
]]></content:encoded></item></channel></rss>