<?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: Riya</title>
    <description>The latest articles on DEV Community by Riya (@riya_123).</description>
    <link>https://dev.to/riya_123</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%2F4191680%2F653f131b-9aff-44db-b692-e9361f34d803.png</url>
      <title>DEV Community: Riya</title>
      <link>https://dev.to/riya_123</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/riya_123"/>
    <language>en</language>
    <item>
      <title>I Built My Own Sync Gateway Instead of Using Couchbase. Here’s How.</title>
      <dc:creator>Riya</dc:creator>
      <pubDate>Sun, 18 Oct 2026 07:40:20 +0000</pubDate>
      <link>https://dev.to/riya_123/i-built-my-own-sync-gateway-instead-of-using-couchbase-heres-how-362k</link>
      <guid>https://dev.to/riya_123/i-built-my-own-sync-gateway-instead-of-using-couchbase-heres-how-362k</guid>
      <description>&lt;h1&gt;
  
  
  I Built My Own Sync Gateway Instead of Using Couchbase. Here's How.
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Push queues, pull checkpoints, safe conflict merges, and keeping secrets off the wire. All from a real offline-first healthcare app.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;When we started on the offline sync for our healthcare app, I seriously considered Couchbase Sync Gateway and Firestore. Both are good. Both handle offline sync well for most apps.&lt;/p&gt;

&lt;p&gt;Then we wrote down our constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Doctors in remote clinics with no signal for hours&lt;/li&gt;
&lt;li&gt;Patient records where an allergy can never be silently overwritten&lt;/li&gt;
&lt;li&gt;PIN hashes and recovery secrets that must never leave the device they were created on&lt;/li&gt;
&lt;li&gt;The whole backend has to run on a laptop in a clinic, with no cloud&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one settled it. We built our own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;Two stores, one protocol. The phone has SQLite with a full local copy and a queue of unsent changes. The server has PostgreSQL as the source of truth, with a &lt;code&gt;server_seq&lt;/code&gt; number on every change.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ww6zeaak7ojajeylzhh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ww6zeaak7ojajeylzhh.png" alt=" " width="799" height="223"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The phone pushes its changes up and pulls the server's changes down. Everything interesting happens in how you order, dedupe and merge those two flows.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The phone never does a plain UPDATE
&lt;/h2&gt;

&lt;p&gt;Every edit creates a new revision with its own hash and a &lt;code&gt;parent_rev&lt;/code&gt; pointing at the version it was based on. In one transaction, we write the revision and add a row to the queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;sync_queue&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;operation_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- UUID, never changes&lt;/span&gt;
  &lt;span class="n"&gt;collection&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;document_id&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;rev&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- pending, sending, sent, failed&lt;/span&gt;
  &lt;span class="n"&gt;attempts&lt;/span&gt;     &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;operation_id&lt;/code&gt; matters more than it looks. If the server saves a change but the response gets lost on a bad connection, the phone retries. The server sees the same &lt;code&gt;operation_id&lt;/code&gt;, says "already have it," and returns ok. No double writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Secrets are stripped before they leave
&lt;/h2&gt;

&lt;p&gt;PIN hashes, recovery hashes and lockout counters live on the device only. The push engine removes them before building the payload, and the server strips them again on the way out of pull. Belt and suspenders.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEVICE_ONLY_FIELDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pin_hash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;recovery_hash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed_pin_attempts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pin_locked_until&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. One push at a time
&lt;/h2&gt;

&lt;p&gt;Every push runs in a Postgres transaction, and the first thing it does is take an advisory lock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT pg_advisory_xact_lock(%s)&lt;/span&gt;&lt;span class="sh"&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;_PUSH_LOCK_KEY&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why? Pull uses &lt;code&gt;server_seq&lt;/code&gt; as a checkpoint. If push A gets seq 10, push B gets seq 11, and B commits first, a pull in between moves its checkpoint past 10. Seq 10 is then missed forever. The lock keeps sequence order equal to commit order. For field-deployment write volumes, the cost is nothing.&lt;/p&gt;

&lt;p&gt;For each incoming change, the server asks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Seen this &lt;code&gt;operation_id&lt;/code&gt;? Return duplicate.&lt;/li&gt;
&lt;li&gt;Does this document belong to this account? If not, reject.&lt;/li&gt;
&lt;li&gt;Does &lt;code&gt;parent_rev&lt;/code&gt; match the server's current rev? Clean write, assign a new &lt;code&gt;server_seq&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Does &lt;code&gt;rev&lt;/code&gt; already equal the server's rev? Idempotent, return ok.&lt;/li&gt;
&lt;li&gt;Otherwise it's a conflict. The server version stays as the winner.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  4. Why last-write-wins wasn't allowed
&lt;/h2&gt;

&lt;p&gt;Two health workers, offline, each add a different allergy to the same patient. Last-write-wins would quietly delete one of them. In a clinic, that's not a bug, it's a risk.&lt;/p&gt;

&lt;p&gt;So we built a safety merge. On a conflict, the server checks whether the losing version has allergies, conditions or medications the winner lacks. If so, it creates a new merged revision and that becomes the truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;list_key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allergies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conditions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medications&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;server_body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;list_key&lt;/span&gt;&lt;span class="p"&gt;,&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;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;incoming_body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;list_key&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="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;
            &lt;span class="n"&gt;changed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;list_key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The merged revision gets &lt;code&gt;source = 'merge'&lt;/code&gt; and its own &lt;code&gt;server_seq&lt;/code&gt;, so every device pulls it. The losing revision isn't deleted. It's kept as &lt;code&gt;conflict_loser&lt;/code&gt;, so there's a full audit trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Pull with a checkpoint, crash-safe
&lt;/h2&gt;

&lt;p&gt;Each account stores a &lt;code&gt;last_pulled_seq&lt;/code&gt;. A pull cycle looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;GET /sync/pull?since={checkpoint}&amp;amp;limit=200&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;For each change: already have the rev, skip it. Have unsent local edits for that doc, run the conflict merge. Otherwise apply it to SQLite.&lt;/li&gt;
&lt;li&gt;Move the checkpoint to the highest seq seen, &lt;strong&gt;in the same transaction&lt;/strong&gt; as the document writes.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;has_more&lt;/code&gt;, repeat.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same transaction is the whole trick. If the app dies halfway through a page, SQLite rolls back the documents and the checkpoint together. The next pull re-fetches that page cleanly. No partial state.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. New phone, same flow
&lt;/h2&gt;

&lt;p&gt;Log in, the gateway returns the profile (with device-only fields already stripped), the checkpoint is set to 0, and a full pull rebuilds the local database. The user sets a fresh PIN, and that hash stays local from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheat sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;What we did&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Offline storage&lt;/td&gt;
&lt;td&gt;SQLite with immutable revisions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate writes&lt;/td&gt;
&lt;td&gt;Unique &lt;code&gt;operation_id&lt;/code&gt; per change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missed sequences&lt;/td&gt;
&lt;td&gt;Postgres advisory transaction lock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delta sync&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;server_seq&lt;/code&gt; checkpoint, paged pull&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conflict audit&lt;/td&gt;
&lt;td&gt;Losers kept as &lt;code&gt;conflict_loser&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safety-critical data&lt;/td&gt;
&lt;td&gt;Domain-aware merge on allergies, meds, conditions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets&lt;/td&gt;
&lt;td&gt;Stripped on client and server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New device&lt;/td&gt;
&lt;td&gt;Authenticate, checkpoint 0, full pull&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The best part: the backend can run on a laptop in a clinic, and every phone syncs to it over a local Wi-Fi hotspot. No cloud needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you build your own?
&lt;/h2&gt;

&lt;p&gt;Probably not, if Couchbase or Firestore fits. But if you have strict conflict rules, secrets that can't leave the device, or no cloud, it's very doable. You need four things: an append-only queue, monotonic sequences, idempotent operations, and conflict rules that match your domain.&lt;/p&gt;

&lt;p&gt;If you want a deep dive on peer-to-peer sync between phones over local Wi-Fi, or how we migrated old queue formats, drop a comment and I'll write it up.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>couchbase</category>
      <category>systemdesign</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
