<?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: Emerson Barcellos</title>
    <description>The latest articles on DEV Community by Emerson Barcellos (@emersonbarcellos0).</description>
    <link>https://dev.to/emersonbarcellos0</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3967133%2Fa922f07b-beb9-4749-b07e-566a2ff39151.jpg</url>
      <title>DEV Community: Emerson Barcellos</title>
      <link>https://dev.to/emersonbarcellos0</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/emersonbarcellos0"/>
    <language>en</language>
    <item>
      <title>Grouped Batch Sync: Reducing Orphan Records in Offline-First Applications</title>
      <dc:creator>Emerson Barcellos</dc:creator>
      <pubDate>Wed, 03 Jun 2026 20:52:59 +0000</pubDate>
      <link>https://dev.to/emersonbarcellos0/grouped-batch-sync-reducing-orphan-records-in-offline-first-applications-59ei</link>
      <guid>https://dev.to/emersonbarcellos0/grouped-batch-sync-reducing-orphan-records-in-offline-first-applications-59ei</guid>
      <description>&lt;h1&gt;
  
  
  Grouped Batch Sync: Reducing Orphan Records in Offline-First Applications
&lt;/h1&gt;

&lt;p&gt;Offline-first synchronization sounds simple until one user action creates several related records.&lt;/p&gt;

&lt;p&gt;Saving data locally is the easy part. The hard part starts later, when the application needs to synchronize that local state with the cloud and with other devices. If every record is synchronized independently, a business operation that was complete on one device can become incomplete on another.&lt;/p&gt;

&lt;p&gt;That is how orphan records appear.&lt;/p&gt;

&lt;p&gt;A receipt may arrive without its payment. A financial entry may point to a receipt that has not arrived yet. Inventory may be updated without the sale or receipt that explains the movement. The local device looks correct, but the rest of the system sees only part of the operation.&lt;/p&gt;

&lt;p&gt;This article describes a practical technique I call &lt;strong&gt;Grouped Batch Sync&lt;/strong&gt;: an offline-first synchronization strategy that combines local queues, operation groups, and technical batches without allowing a batch limit to split a business operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Record-by-Record Sync
&lt;/h2&gt;

&lt;p&gt;Many offline-first systems start with a local queue:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Save data locally.&lt;/li&gt;
&lt;li&gt;Add a pending item to the sync queue.&lt;/li&gt;
&lt;li&gt;Send pending items when the network is available.&lt;/li&gt;
&lt;li&gt;Remove accepted items from the queue.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This works well when one user action maps to one record.&lt;/p&gt;

&lt;p&gt;It becomes fragile when one user action maps to multiple records.&lt;/p&gt;

&lt;p&gt;For example, saving a receipt can create:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a receipt record;&lt;/li&gt;
&lt;li&gt;a payment record;&lt;/li&gt;
&lt;li&gt;a financial entry;&lt;/li&gt;
&lt;li&gt;inventory movements.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If each record enters the sync queue as an independent item, they may be sent in different requests, accepted at different times, or rejected independently. Another device may observe the operation halfway through.&lt;/p&gt;

&lt;p&gt;That is not just a visual bug. It can create real business inconsistency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Idea
&lt;/h2&gt;

&lt;p&gt;Grouped Batch Sync adds one concept to the local queue: an operation group.&lt;/p&gt;

&lt;p&gt;When a user action starts, the application creates a &lt;code&gt;syncGroupId&lt;/code&gt;. Every record generated by that action carries the same group identifier.&lt;/p&gt;

&lt;p&gt;The sync engine still sends technical batches, but the orchestrator preserves logical groups inside those batches.&lt;/p&gt;

&lt;p&gt;In other words:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Technical batch size should optimize transport. It should not be allowed to break a business operation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Technical Batch vs. Logical Group
&lt;/h2&gt;

&lt;p&gt;These two concepts are related, but they solve different problems.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;technical batch&lt;/strong&gt; exists for performance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;send at most N records at a time;&lt;/li&gt;
&lt;li&gt;avoid large payloads;&lt;/li&gt;
&lt;li&gt;reduce timeouts;&lt;/li&gt;
&lt;li&gt;protect the API from oversized requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;strong&gt;logical group&lt;/strong&gt; exists for consistency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep receipt, payment, and financial entry together;&lt;/li&gt;
&lt;li&gt;keep order and items together;&lt;/li&gt;
&lt;li&gt;keep deletion and reversal records together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Grouped Batch Sync combines both. It sends records in batches, but it treats records with the same &lt;code&gt;syncGroupId&lt;/code&gt; as a unit when building those batches.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Minimal Example
&lt;/h2&gt;

&lt;p&gt;Imagine this local operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;SyncOperationContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;groupId:&lt;/span&gt; &lt;span class="n"&gt;SyncOperationContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createGroupId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;type:&lt;/span&gt; &lt;span class="s"&gt;'receipt-create'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;rootId:&lt;/span&gt; &lt;span class="s"&gt;'receipt-001'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nl"&gt;groupType:&lt;/span&gt; &lt;span class="s"&gt;'receipt-create'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;action:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'receipts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'receipt-001'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;'total'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;120.0&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'payments'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'payment-001'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="s"&gt;'receiptId'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'receipt-001'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s"&gt;'amount'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;120.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'financial_entries'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'entry-001'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="s"&gt;'reference'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'receipt-001'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s"&gt;'amount'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;120.0&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those three records are independent records, but they belong to the same business operation.&lt;/p&gt;

&lt;p&gt;Now add two unrelated records:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'products'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'product-001'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;'name'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'Paper roll'&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'customers'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'customer-001'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;'name'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'Ada'&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then flush the queue with a technical batch size of &lt;code&gt;2&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;orchestrator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flushPending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;batchSize:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A normal batching algorithm might send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Batch 1:
- receipts:receipt-001
- payments:payment-001

Batch 2:
- financial_entries:entry-001
- products:product-001

Batch 3:
- customers:customer-001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That splits the receipt operation.&lt;/p&gt;

&lt;p&gt;Grouped Batch Sync instead produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Batch 1 (3 records)
- receipts:receipt-001 action=upsert group=receipt-create / receipt-create:receipt-001:...
- payments:payment-001 action=upsert group=receipt-create / receipt-create:receipt-001:...
- financial_entries:entry-001 action=upsert group=receipt-create / receipt-create:receipt-001:...

Batch 2 (2 records)
- products:product-001 action=upsert group=no-group
- customers:customer-001 action=upsert group=no-group
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even with &lt;code&gt;batchSize: 2&lt;/code&gt;, the first batch contains 3 records because they belong to the same logical operation.&lt;/p&gt;

&lt;p&gt;This is the key behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Orchestrator Rule
&lt;/h2&gt;

&lt;p&gt;The batching algorithm is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;for each pending item:
  if item was already consumed:
    continue

  if item has syncGroupId:
    nextItems = all pending items with the same syncGroupId
  else:
    nextItems = only this item

  if nextItems do not fit in the current batch:
    flush current batch

  add nextItems to current batch
  mark nextItems as consumed

flush last batch
send each batch to the API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means a logical group can exceed the technical batch size. That tradeoff is intentional. The batch size is a transport optimization; the operation group represents business consistency.&lt;/p&gt;

&lt;p&gt;In a production system, you may still want a maximum group size. But that should be a separate rule with explicit handling, not an accidental split caused by generic batching.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Solves
&lt;/h2&gt;

&lt;p&gt;Grouped Batch Sync helps reduce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;orphan records;&lt;/li&gt;
&lt;li&gt;incomplete states across devices;&lt;/li&gt;
&lt;li&gt;partial visibility of composite operations;&lt;/li&gt;
&lt;li&gt;difficult-to-debug sync failures;&lt;/li&gt;
&lt;li&gt;cases where one device looks correct and another sees only half of the operation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is especially useful when an offline-first app has actions that create side effects across several entities.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Does Not Solve
&lt;/h2&gt;

&lt;p&gt;This is not a universal solution for every offline-first problem.&lt;/p&gt;

&lt;p&gt;You still need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;conflict detection;&lt;/li&gt;
&lt;li&gt;conflict resolution policies;&lt;/li&gt;
&lt;li&gt;record versioning;&lt;/li&gt;
&lt;li&gt;idempotent server endpoints;&lt;/li&gt;
&lt;li&gt;server-side validation;&lt;/li&gt;
&lt;li&gt;retry handling;&lt;/li&gt;
&lt;li&gt;deletion semantics;&lt;/li&gt;
&lt;li&gt;observability for rejected records;&lt;/li&gt;
&lt;li&gt;transactions if you need true atomic behavior on the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Grouped Batch Sync does not magically make distributed systems simple. It solves a narrower but important problem: preserving the shape of a composite business operation while it moves through an offline-first sync pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Notes
&lt;/h2&gt;

&lt;p&gt;The method can be implemented with a few small pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An operation context that stores the active &lt;code&gt;syncGroupId&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A queue layer that attaches the group to every local change created inside that context.&lt;/li&gt;
&lt;li&gt;A queue item model that stores &lt;code&gt;syncGroupId&lt;/code&gt; and &lt;code&gt;syncGroupType&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A sync orchestrator that chunks pending items while preserving groups.&lt;/li&gt;
&lt;li&gt;A server endpoint that receives batches and applies validation consistently.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In Dart, an operation context can be implemented with &lt;code&gt;Zone&lt;/code&gt;, which keeps contextual values available across asynchronous calls. Other ecosystems can implement the same idea with async-local storage, coroutine context, request context, or explicit parameters.&lt;/p&gt;

&lt;p&gt;The concept is not tied to Dart or Flutter. The pattern is portable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;Offline-first applications are becoming more common, especially in mobile, field operations, point-of-sale systems, local-first tools, and multi-device business apps.&lt;/p&gt;

&lt;p&gt;But offline-first is not only about "saving while offline." It is about preserving meaning while data moves through time, devices, networks, retries, and conflicts.&lt;/p&gt;

&lt;p&gt;When a user performs one business action, the system should try to preserve that action as a coherent unit.&lt;/p&gt;

&lt;p&gt;Grouped Batch Sync is one way to do that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The mistake is letting transport batching define business boundaries.&lt;/p&gt;

&lt;p&gt;Technical batches are useful. They protect performance and reliability. But when a technical batch splits a logical operation, the sync system can create orphan records and incomplete states.&lt;/p&gt;

&lt;p&gt;Grouped Batch Sync keeps these concerns separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;batches optimize transport;&lt;/li&gt;
&lt;li&gt;groups preserve business meaning.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That distinction is small, but in real offline-first applications, it can remove a lot of pain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repository
&lt;/h2&gt;

&lt;p&gt;The example and technical documentation are available here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/emersonbarcellos/grouped-batch-sync" rel="noopener noreferrer"&gt;https://github.com/emersonbarcellos/grouped-batch-sync&lt;/a&gt;&lt;/p&gt;

</description>
      <category>offlinefirst</category>
      <category>sync</category>
      <category>distributedsystems</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
