<?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: Andy Stanly</title>
    <description>The latest articles on DEV Community by Andy Stanly (@andystanly).</description>
    <link>https://dev.to/andystanly</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%2F4143331%2F5019e547-15e5-4c72-b16d-bbfb36672c75.png</url>
      <title>DEV Community: Andy Stanly</title>
      <link>https://dev.to/andystanly</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andystanly"/>
    <language>en</language>
    <item>
      <title>The sync conflict that ate a year of highlights</title>
      <dc:creator>Andy Stanly</dc:creator>
      <pubDate>Sat, 26 Sep 2026 17:34:47 +0000</pubDate>
      <link>https://dev.to/andystanly/the-sync-conflict-that-ate-a-year-of-highlights-42c1</link>
      <guid>https://dev.to/andystanly/the-sync-conflict-that-ate-a-year-of-highlights-42c1</guid>
      <description>&lt;h2&gt;
  
  
  The row
&lt;/h2&gt;

&lt;p&gt;Text.&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;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>sqlite</category>
      <category>sync</category>
    </item>
    <item>
      <title>The sync conflict that ate a year of highlights</title>
      <dc:creator>Andy Stanly</dc:creator>
      <pubDate>Sat, 26 Sep 2026 16:58:52 +0000</pubDate>
      <link>https://dev.to/andystanly/the-sync-conflict-that-ate-a-year-of-highlights-433</link>
      <guid>https://dev.to/andystanly/the-sync-conflict-that-ate-a-year-of-highlights-433</guid>
      <description>&lt;h2&gt;
  
  
  The row
&lt;/h2&gt;

&lt;p&gt;Text.&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;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>sqlite</category>
      <category>sync</category>
    </item>
    <item>
      <title>The sync conflict that ate a year of highlights</title>
      <dc:creator>Andy Stanly</dc:creator>
      <pubDate>Sat, 26 Sep 2026 15:34:43 +0000</pubDate>
      <link>https://dev.to/andystanly/the-sync-conflict-that-ate-a-year-of-highlights-4neb</link>
      <guid>https://dev.to/andystanly/the-sync-conflict-that-ate-a-year-of-highlights-4neb</guid>
      <description>&lt;h2&gt;
  
  
  The row
&lt;/h2&gt;

&lt;p&gt;Text.&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;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>sqlite</category>
      <category>sync</category>
    </item>
    <item>
      <title>The sync conflict that ate a year of highlights</title>
      <dc:creator>Andy Stanly</dc:creator>
      <pubDate>Sat, 26 Sep 2026 15:26:47 +0000</pubDate>
      <link>https://dev.to/andystanly/the-sync-conflict-that-ate-a-year-of-highlights-4h6m</link>
      <guid>https://dev.to/andystanly/the-sync-conflict-that-ate-a-year-of-highlights-4h6m</guid>
      <description>&lt;h2&gt;
  
  
  The row
&lt;/h2&gt;

&lt;p&gt;Text.&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;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>sqlite</category>
      <category>sync</category>
    </item>
    <item>
      <title>The sync conflict that ate a year of highlights</title>
      <dc:creator>Andy Stanly</dc:creator>
      <pubDate>Sat, 26 Sep 2026 15:21:15 +0000</pubDate>
      <link>https://dev.to/andystanly/the-sync-conflict-that-ate-a-year-of-highlights-2jn3</link>
      <guid>https://dev.to/andystanly/the-sync-conflict-that-ate-a-year-of-highlights-2jn3</guid>
      <description>&lt;h2&gt;
  
  
  The row
&lt;/h2&gt;

&lt;p&gt;Text.&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;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>sqlite</category>
      <category>sync</category>
    </item>
    <item>
      <title>The sync conflict that quietly ate a year of highlights, and the row that fixed it</title>
      <dc:creator>Andy Stanly</dc:creator>
      <pubDate>Fri, 25 Sep 2026 17:48:09 +0000</pubDate>
      <link>https://dev.to/andystanly/the-sync-conflict-that-quietly-ate-a-year-of-highlights-and-the-row-that-fixed-it-2kmb</link>
      <guid>https://dev.to/andystanly/the-sync-conflict-that-quietly-ate-a-year-of-highlights-and-the-row-that-fixed-it-2kmb</guid>
      <description>&lt;p&gt;I keep a &lt;code&gt;surprises.md&lt;/code&gt; file in the repo. It is not documentation. It is a list of things that made me say &lt;em&gt;huh&lt;/em&gt; out loud, usually at night. The entry from three weeks ago is one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;SELECT COUNT(*) FROM highlights WHERE deleted_at IS NOT NULL;&lt;/code&gt; -&amp;gt; 41,208&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Forty-one thousand rows with a tombstone. A year and a bit of reading, marked gone. Nobody deleted them. The system did, on its own, because two devices disagreed about who was allowed to speak last.&lt;/p&gt;

&lt;h2&gt;
  
  
  The original decision, and why it was right then
&lt;/h2&gt;

&lt;p&gt;When I built the highlight sync, the constraint was simple: you can highlight a verse on a phone with no signal, and it should still be there when you open the app on a different device. That means local writes are authoritative until they are not. I did not want a server round-trip in the middle of someone's quiet time.&lt;/p&gt;

&lt;p&gt;So I made highlights a last-write-wins register. Every row had a &lt;code&gt;server_seq&lt;/code&gt; column, a monotonic integer bumped on every write by the server. When a device pushed its local queue, the server compared the incoming &lt;code&gt;server_seq&lt;/code&gt; to what it had. Higher number wins. Lower number is dropped.&lt;/p&gt;

&lt;p&gt;That was the whole policy. It was cheap to reason about, it never required a merge UI, and it meant the common case -- one device, occasionally syncing -- was trivially correct. I wrote a test for it. The test passed for two years.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;code&gt;server_seq&lt;/code&gt; is a server number. It knows nothing about the order in which the &lt;em&gt;user&lt;/em&gt; made changes. And in a last-write-wins register, that is the only order that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;Two devices, one account. Device A is offline for a week. Device B syncs first. The server's &lt;code&gt;server_seq&lt;/code&gt; climbs. Then Device A comes back and pushes the queue it accumulated during the week -- ten highlights, all created before the ones on Device B, but all carrying local timestamps that the server never looked at. The server assigns them new &lt;code&gt;server_seq&lt;/code&gt; values. They win.&lt;/p&gt;

&lt;p&gt;An hour later, Device B syncs again, pushing its own queue. Its local changes are older than Device A's, but the server has no way to know that, so it bumps &lt;code&gt;server_seq&lt;/code&gt; again. They win. Device B overwrites the week. Device A syncs, overwrites Device B. Back and forth.&lt;/p&gt;

&lt;p&gt;Except it was not symmetric. The real damage was the tombstone. When a highlight was removed on one device -- say, because it belonged to a reading plan the user had already finished -- that removal was queued as a delete. The delete carried no timestamp either. On the next sync, the server applied it blindly, filtered it through last-write-wins, and the row was marked &lt;code&gt;deleted_at&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The user's other device still had the highlight. It would sync and resurrect it. Then the first device would sync again, and the tombstone would win again by &lt;code&gt;server_seq&lt;/code&gt;. After a few cycles, the resurrection stopped. The row stayed dead. The user saw nothing. There was no error, no notification, no conflict panel. Just a verse that used to be highlighted, and then was not.&lt;/p&gt;

&lt;p&gt;Forty-one thousand rows is what that looks like at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism I should have shipped
&lt;/h2&gt;

&lt;p&gt;Last-write-wins is not wrong. It is wrong &lt;em&gt;as a default with no tiebreaker&lt;/em&gt;. The fix was not to build a merge UI or a CRDT. It was to give the server one more number: a client timestamp, in milliseconds, taken from the device clock at the moment the change was made.&lt;/p&gt;

&lt;p&gt;The rule became: if two writes collide on the same row, the one with the later &lt;code&gt;client_ts&lt;/code&gt; wins. If &lt;code&gt;client_ts&lt;/code&gt; is equal -- which happens more than you would think, because mobile clocks are coarse -- fall back to &lt;code&gt;server_seq&lt;/code&gt;, which is at least deterministic.&lt;/p&gt;

&lt;p&gt;That is it. One column. The diff is eleven lines of migration and a slightly different &lt;code&gt;UPSERT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The reason it took me three days instead of an hour is that I did not trust the client clock. And I still do not, entirely. Devices lie. I have seen a phone set to 1970 because the battery died and the OS did not have a chance to set the clock. So the server clamps any incoming &lt;code&gt;client_ts&lt;/code&gt; to &lt;code&gt;[min_reasonable, now + 5 minutes]&lt;/code&gt;. A write that arrives outside that window gets the server's current time instead, and it is logged. We have had eleven of those in two months. All from the same model of cheap Android tablet, all with dead batteries.&lt;/p&gt;

&lt;p&gt;If the client clock is wrong by more than the clamp, you are back to nondeterministic ordering. That is a real hole. I have not closed it. My current position is that a device with a clock wrong by hours is a device whose user has bigger problems, and that the clamp at least stops the &lt;em&gt;silent&lt;/em&gt; overwrite. The tombstone problem, specifically, is now solved by the timestamp, because a delete made last week has a &lt;code&gt;client_ts&lt;/code&gt; of last week and will not outrank a highlight made five minutes ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check in your own schema
&lt;/h2&gt;

&lt;p&gt;If you have a table that syncs and a &lt;code&gt;last_write_wins&lt;/code&gt; policy, open the migration and look for a client-side clock column. If it is not there, you have the same bug I had. It will not announce itself. It will show up as a user complaint that &lt;em&gt;something they highlighted is missing&lt;/em&gt;, and you will not be able to reproduce it, because to reproduce it you need two devices, one offline for a week, and a delete queued in the gap.&lt;/p&gt;

&lt;p&gt;I ran this query against production before I shipped the migration:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tombstones&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;highlights&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;tombstones&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The top row had 412 tombstones. That user had two devices and a habit of reading offline on a plane. The data was still there -- soft delete -- so I could reconstruct the highlights from the tombstones and the local queues. I wrote a one-off script to un-delete anything with a &lt;code&gt;client_ts&lt;/code&gt; later than its &lt;code&gt;deleted_at&lt;/code&gt; minus a small grace window. It restored most of them. It could not restore the ones where both timestamps were absent, because those rows predate the migration.&lt;/p&gt;

&lt;p&gt;I do not have a good answer for those. I have an email drafted to the affected users that I have not sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I landed, and where I have not
&lt;/h2&gt;

&lt;p&gt;I still do not love the client clock. It is a number I do not control, written by hardware I do not own, and I am using it to decide whose edit survives. But the alternative -- server sequence as the only ordering -- was demonstrably worse. The server sequence is a fact about &lt;em&gt;when the server heard about the change&lt;/em&gt;, not about when the change happened. I had been treating those as the same thing for two years.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is that this was not a bug in the code. The code did what it was written to do. It was a bug in the model, and the model was my decision, and it was correct for the constraints I had at the time. The constraint that changed was the number of devices per user. When I wrote it, most users had one. When it broke, most users had two or three, and the ones with two or three were the ones who read the most.&lt;/p&gt;

&lt;p&gt;I keep coming back to something I have written on the whiteboard above my desk: &lt;em&gt;the interface is the only part anyone sees, and the data model is the only part that matters.&lt;/em&gt; It is not clever. It is just true, and I forget it every time a feature looks small.&lt;/p&gt;

&lt;p&gt;The migration went out on a Tuesday. I watched the tombstone count for a week. It stopped climbing. I have not looked at it since, which is either confidence or avoidance, and I am not sure which.&lt;/p&gt;

</description>
      <category>sync</category>
      <category>sqlite</category>
      <category>datamodel</category>
      <category>offline</category>
    </item>
  </channel>
</rss>
