<?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: Abhishek Goyal</title>
    <description>The latest articles on DEV Community by Abhishek Goyal (@abhishek_goyal_c97dbf2eea).</description>
    <link>https://dev.to/abhishek_goyal_c97dbf2eea</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%2F4141403%2F8d0b3bdd-e83d-4a13-8fb0-2a06714194df.png</url>
      <title>DEV Community: Abhishek Goyal</title>
      <link>https://dev.to/abhishek_goyal_c97dbf2eea</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abhishek_goyal_c97dbf2eea"/>
    <language>en</language>
    <item>
      <title>Offline-first with no server: why HelperBook is local-only</title>
      <dc:creator>Abhishek Goyal</dc:creator>
      <pubDate>Thu, 24 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/abhishek_goyal_c97dbf2eea/offline-first-with-no-server-why-helperbook-is-local-only-13ha</link>
      <guid>https://dev.to/abhishek_goyal_c97dbf2eea/offline-first-with-no-server-why-helperbook-is-local-only-13ha</guid>
      <description>&lt;p&gt;A lot of my work has been on offline-first apps that still, eventually, talk to a server. At Pulse, I designed the sync contract with the backend and web teams: payloads, upload semantics, failure behaviour. The app worked without signal, but it was always working towards a moment when it would have signal again and reconcile.&lt;/p&gt;

&lt;p&gt;HelperBook, the app I’m building now for Indian households to track staff attendance, advances and salary settlements, doesn’t have that moment. There is no signup and no server. Records live on the device, full stop. I want to explain why that’s a design decision and not a missing feature, and what it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data is the reason not to have a server
&lt;/h2&gt;

&lt;p&gt;HelperBook stores wage data: how many days someone worked, what they were advanced, what they’re owed at month end. That’s exactly the kind of data a household might hesitate to put into an app if it thought the numbers were going somewhere.&lt;/p&gt;

&lt;p&gt;Keeping wage data off any server keeps the app outside data-fiduciary scope under India’s DPDP Act, and it gives households a reason to enter real numbers. Those two things are connected. An app that can’t leak your payroll data because it never has it in the first place is a different trust proposition than one that promises to look after your payroll data on a server somewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  No sync engine to design
&lt;/h2&gt;

&lt;p&gt;The technical consequence is that there’s no sync contract to design, no conflict resolution, no retry queue, no “what happens if the upload fails halfway through” states. Records live on-device via SQLDelight, inside a shared Kotlin Multiplatform module. Users own their data through export and restore.&lt;/p&gt;

&lt;p&gt;That’s a smaller surface area than Pulse’s sync engine, and it’s smaller on purpose. Every state a sync engine has to handle — offline, online, mid-sync, conflicting edits from two devices — is a state HelperBook doesn’t have. The trade-off is direct: no server means no multi-device. If a household member wants to check the ledger from a second phone, they can’t. And if the phone is lost or wiped without an export first, the data is gone. There’s no account to recover it from. That’s a real cost, and it’s one I chose deliberately rather than one I didn’t notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the honesty into the data model, not the UI
&lt;/h2&gt;

&lt;p&gt;A local-only app has one more responsibility than a server-backed one: the rules have to be right, because there’s no admin panel on the backend to fix a bad month for someone later. So the product rules that matter most live in the data model rather than as UI copy or a setting someone can miss.&lt;/p&gt;

&lt;p&gt;Unmarked days default to present, so a worker is never underpaid because someone forgot to tap. Half-days deduct exactly half, with no rounding surprises at month end. And settlement statements are generated bilingually, in English and Hindi, so the employer and the helper can both read every line without one of them having to trust the other’s translation.&lt;/p&gt;

&lt;p&gt;As a simplified illustration, the “unmarked day” rule looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified illustration of the rule, not HelperBook's actual code.&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;attendanceValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DayMark&lt;/span&gt;&lt;span class="p"&gt;?):&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mark&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;DayMark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ABSENT&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="nc"&gt;DayMark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HALF_DAY&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;
    &lt;span class="nc"&gt;DayMark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PRESENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part isn’t the code, it’s the default: a missing mark resolves to &lt;code&gt;1.0&lt;/code&gt;, not &lt;code&gt;0.0&lt;/code&gt;. In a local-only app, that decision is the whole safety net. There’s no support ticket, no server-side correction, no second system checking the math. Whatever the function returns is what the worker gets paid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Release engineering without a backend to hide behind
&lt;/h2&gt;

&lt;p&gt;Going local-only doesn’t remove the need for release discipline, it just moves where it matters. HelperBook ships as a signed AAB with R8 rules written for SQLDelight and kotlinx-serialization, so obfuscation doesn’t break the database layer or the serialised export format. Crashlytics is wired in, but the logs carry no names or amounts. Play’s Data Safety declarations describe what the app actually does. And there’s a staged-rollout plan, so a wider release doesn’t reach everyone at once.&lt;/p&gt;

&lt;p&gt;None of that is specific to offline-first apps. But when there’s no backend team and no server logs to lean on for debugging, the discipline around what you ship and how you roll it out is the only safety net you have. It’s currently in closed beta on Google Play with 12–20 households, built with Jetpack Compose, min SDK 24, target API 35.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I’d tell another builder
&lt;/h2&gt;

&lt;p&gt;If you’re weighing whether a product needs a backend at all, the question I’d ask first isn’t “can we build the sync engine,” it’s “what does the user lose if there’s no sync engine.” For HelperBook, the loss is multi-device access and recovery-without-export, and I judged that a household tracking one home’s payroll would rather have neither of those than have their wage data sitting on a server. For a different product, that trade might go the other way.&lt;/p&gt;

&lt;p&gt;The second thing I’d say: local-only doesn’t mean less engineering. It means the engineering moves into the data model and the release pipeline instead of into a sync contract. You still have to get the rules right, you still have to obfuscate and log carefully, you still have to plan the rollout in stages. You just don’t get to blame a server for anything.&lt;/p&gt;

&lt;p&gt;More on how HelperBook is built is in the &lt;a href="https://abhishekgoyal.me/work/helperbook" rel="noopener noreferrer"&gt;case study&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>offlinefirst</category>
      <category>kotlinmultiplatform</category>
      <category>privacy</category>
      <category>android</category>
    </item>
    <item>
      <title>Offline-first with a server: lessons from Pulse's sync engine</title>
      <dc:creator>Abhishek Goyal</dc:creator>
      <pubDate>Thu, 24 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/abhishek_goyal_c97dbf2eea/offline-first-with-a-server-lessons-from-pulses-sync-engine-dng</link>
      <guid>https://dev.to/abhishek_goyal_c97dbf2eea/offline-first-with-a-server-lessons-from-pulses-sync-engine-dng</guid>
      <description>&lt;p&gt;The &lt;a href="https://abhishekgoyal.me/writing/offline-first-without-a-server" rel="noopener noreferrer"&gt;local-only HelperBook&lt;/a&gt; post is about an app that never talks to a server, and what that buys you. Pulse is the opposite case: an offline-first inspection app for auditors that has to reach a server eventually, because the whole point is that the data ends up somewhere other people can see it. I owned mobile for Pulse, Android and iOS, from an early prototype to 100+ B2B enterprise clients. The sync contract (payloads, upload semantics, failure behaviour) was something I designed with the backend and web teams before implementing both native clients against it. This is what I learned building it, not a walkthrough of how it works end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Records need an identity before the server has one
&lt;/h2&gt;

&lt;p&gt;An auditor can start an inspection with no signal at all. The form has to exist as soon as they start filling it in, which means it needs an ID before the server has assigned one. The app solved this with a temporary local ID, created on the device the moment the record starts. When the device reconnects and the record is created on the server, the app gets back a server ID and remaps the form’s answers and any media against it.&lt;/p&gt;

&lt;p&gt;That remapping is the part that’s easy to underestimate. Every piece of local state that referred to the temporary ID — form answers, photo attachments, upload queue entries — has to be repointed to the server ID without losing anything or duplicating anything. And it has to survive retries: if the same record tries to sync again after a failure, it has to reuse the server ID it already has rather than asking for a new one, or the server would end up with two records for the same inspection. As a simplified illustration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified illustration, not Pulse's actual code.&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;idForUpload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;LocalRecord&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;RecordId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serverId&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;assigned&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRecordOnServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serverId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;assigned&lt;/span&gt;
        &lt;span class="n"&gt;assigned&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cost of this is real: you’re carrying two ID spaces through the whole client, and every piece of code that touches a record needs to know which one it’s holding. But the alternative — waiting for a server ID before letting the auditor start work — isn’t available to an offline-first app. If there’s no signal, there’s no server round trip to wait for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upload order is a correctness decision, not a performance one
&lt;/h2&gt;

&lt;p&gt;Inspection forms come with photos, and photos are larger and slower to upload than form data. It would be tempting to submit the form first, since that’s the thing the auditor is waiting on, and let the photos trail in behind it. Pulse did the opposite: photos were uploaded first, in the background, while the auditor was still working, and the form was only submitted once every photo attached to it was confirmed on the server.&lt;/p&gt;

&lt;p&gt;The reason is what that ordering prevents. If the form went first, the server would briefly, or not so briefly on a bad connection, hold a submitted inspection with photos missing. Anyone looking at that record from the web or backend side would see a completed inspection with holes in it, and there’d be no clean way to tell “still uploading” apart from “photo was never taken.” Uploading media first and gating the form submission on it means the server never has to represent that in-between state at all. The trade-off is that a form can be finished on the device and still not “done” from the server’s point of view, which is exactly the kind of thing that has to be visible to the person waiting on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Status has to be visible, or people don’t trust the sync
&lt;/h2&gt;

&lt;p&gt;That last point led to one of the changes I’m gladdest we made, and it came out of a real problem: auditors weren’t sure whether their work had actually reached the server once they’d submitted a form. The submission felt finished on their screen, but the photos might still be uploading in the background, and there was nothing telling them that. From their side, “I submitted it” and “it’s on the server” looked identical, and they had no way to tell those two states apart.&lt;/p&gt;

&lt;p&gt;The fix was to stop treating sync as something that happens invisibly after submission and start showing it: once a form was submitted, the UI showed upload progress and a count of items still left to go up. It’s a small piece of UI, but it changed what “submitted” meant to the person using the app. Instead of a single state that quietly covered “done” and “still working on it,” there were two, and the auditor could see which one they were in. The lesson generalises past Pulse: any offline-first system is going to have a gap between “the user is finished” and “the server has it,” and if that gap isn’t visible, users will assume the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conflicts are mostly designed away, but not entirely
&lt;/h2&gt;

&lt;p&gt;Most of the time two people weren’t editing the same inspection record at once, and the app was built so that was the common case rather than something handled after the fact. But an offline-first system can’t promise that never happens — a record can be edited on two devices before either has synced, and eventually both versions reach the server. When that happened, the default was last write wins: whichever version reaches the server second was the one that was kept.&lt;/p&gt;

&lt;p&gt;Last write wins is a reasonable default because it’s simple and it doesn’t block anyone waiting to sync. But it’s also exactly the kind of thing that can silently overwrite someone’s work, which is why it isn’t the whole answer here. When a conflict was detected, the user saw a warning and could choose to keep their local version instead of the one that had just landed. That warning is the important part. A conflict-resolution strategy that resolves quietly is only safe if you’re confident it never matters which side wins; the moment that’s not certain, the person who did the work needs the chance to say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowing nothing is actually getting lost
&lt;/h2&gt;

&lt;p&gt;None of this is worth much if you can’t tell whether it’s working. The way I had visibility into that was logging and monitoring on the upload pipeline itself — failures were visible, not silent, so a stuck or failing upload showed up rather than disappearing. That’s a narrower claim than “we proved nothing was ever lost”; it’s closer to “we could see when something didn’t make it, instead of finding out from someone else much later.” Over four and a half years of field use, no data-loss incidents were reported. The ordering and remapping decisions above are what kept data safe; the monitoring is what makes “none reported” mean something, because a failed upload would have shown up in the logs rather than waiting for someone to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I’d tell another builder
&lt;/h2&gt;

&lt;p&gt;If you’re building sync into an offline-first app, the identity problem comes first: decide early how a record gets an ID before the server has assigned one, and plan for the remapping that follows, because it touches everything downstream.&lt;/p&gt;

&lt;p&gt;Second, think about upload order as a correctness tool, not just a performance one. Whatever order your uploads go in defines what invalid in-between states your server can end up holding, so pick the order that minimises those states rather than the order that feels fastest to the user.&lt;/p&gt;

&lt;p&gt;Third, don’t let “submitted” and “synced” collapse into one state in the UI. They’re different things, and the moment users can’t tell them apart is the moment they stop trusting the app to have their data.&lt;/p&gt;

&lt;p&gt;And last, pick a conflict strategy you can explain in one sentence, then make sure the person whose work might get overwritten by it gets a say. Last write wins is fine as a default. It’s only fine because there’s a warning attached to it.&lt;/p&gt;

&lt;p&gt;More on how the Pulse sync engine fits together is in the &lt;a href="https://abhishekgoyal.me/work/pulse" rel="noopener noreferrer"&gt;case study&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>offlinefirst</category>
      <category>sync</category>
      <category>android</category>
      <category>ios</category>
    </item>
  </channel>
</rss>
