<?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>Two native apps into one React Native codebase in six months</title>
      <dc:creator>Abhishek Goyal</dc:creator>
      <pubDate>Thu, 08 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/abhishek_goyal_c97dbf2eea/two-native-apps-into-one-react-native-codebase-in-six-months-543j</link>
      <guid>https://dev.to/abhishek_goyal_c97dbf2eea/two-native-apps-into-one-react-native-codebase-in-six-months-543j</guid>
      <description>&lt;p&gt;I owned mobile for Pulse, an offline-first inspection app for auditors, on Android and iOS, from an early prototype to 100+ B2B enterprise clients. For most of that time it was two native apps: Kotlin on Android, Swift on iOS, kept in feature parity. In 2025 I led a full rewrite of both into a single React Native codebase. I architected it and built it alongside the team, and we completed it in six months, before I left in May 2025.&lt;/p&gt;

&lt;p&gt;This isn’t a “native versus React Native” post. The native apps worked. The earlier posts on&lt;a href="https://abhishekgoyal.me/writing/offline-first-sync-lessons" rel="noopener noreferrer"&gt;Pulse’s sync engine&lt;/a&gt; and on&lt;a href="https://abhishekgoyal.me/writing/offline-first-without-a-server" rel="noopener noreferrer"&gt;a local-only app&lt;/a&gt; cover why they were built the way they were. This one is about the rewrite: why we did it, what we kept, and how it reached users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why rewrite two apps that worked
&lt;/h2&gt;

&lt;p&gt;Keeping two native apps in feature parity has a simple cost: every feature is built twice, once in Kotlin and once in Swift, and every fix has to land in both. That was the reason for the rewrite, in two parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One codebase to maintain&lt;/strong&gt; instead of two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shipping faster&lt;/strong&gt; , because a feature or a fix reaches Android and iOS at the same time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither reason says the native apps were bad. They say that keeping two of them had a cost, and one codebase removes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replicate the behaviour, don’t redesign it
&lt;/h2&gt;

&lt;p&gt;The native apps’ behaviour had years of field use behind it: records that get a temporary local ID and are remapped to a server ID when they sync, photos uploaded before the form that references them, upload progress the auditor can see, and inspection forms built dynamically. So the React Native app replicated that behaviour rather than redesigning it.&lt;/p&gt;

&lt;p&gt;The alternative is harder than it looks. A rewrite that also changes how sync works is two projects at once, and every difference between the old app and the new one is something to explain to an auditor in the field. Replicating proven behaviour keeps the rewrite to one question: does the new app do what the old one did?&lt;/p&gt;

&lt;h2&gt;
  
  
  I built the core, the team built on top
&lt;/h2&gt;

&lt;p&gt;I set the architecture and built the core myself: sync, offline storage and uploads. The team built the screens and features on top of it.&lt;/p&gt;

&lt;p&gt;In an offline-first app, that core is where the risk sits. Everything on screen depends on the core behaving the way the native apps did, and in an offline-first app the core is where data gets lost if something is wrong. Screens are easier to see, test and fix; a sync bug can hide until an auditor’s work goes missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling out gradually
&lt;/h2&gt;

&lt;p&gt;The new app didn’t replace the native ones for everyone at once. It rolled out gradually, with the native app still available while users moved over.&lt;/p&gt;

&lt;p&gt;For an offline-first app that isn’t caution for its own sake. Auditors work without signal, so at any moment someone may have work on the phone that hasn’t reached the server yet. A gradual rollout means a problem in the new app reaches a few users rather than all of them, and there is still a working app to fall back on while it’s fixed.&lt;/p&gt;

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

&lt;p&gt;If you’re rewriting an offline-first app, roll it out gradually. Keep the old app available and move users over in stages. A big-bang switch bets every auditor’s unsynced work on the new app being right on day one.&lt;/p&gt;

&lt;p&gt;More on Pulse 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>reactnative</category>
      <category>android</category>
      <category>ios</category>
      <category>offlinefirst</category>
    </item>
    <item>
      <title>How to hire an app developer: what to have ready before the first call</title>
      <dc:creator>Abhishek Goyal</dc:creator>
      <pubDate>Tue, 06 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/abhishek_goyal_c97dbf2eea/how-to-hire-an-app-developer-what-to-have-ready-before-the-first-call-4k2g</link>
      <guid>https://dev.to/abhishek_goyal_c97dbf2eea/how-to-hire-an-app-developer-what-to-have-ready-before-the-first-call-4k2g</guid>
      <description>&lt;p&gt;Most people who want an app built don’t write code, and they shouldn’t have to. But the first call with a developer goes much better when you’ve done a little homework. Not a spec, and not a tech stack. Just a clear picture of the problem, and a few questions of your own.&lt;/p&gt;

&lt;p&gt;I’ve been building Android and iOS apps for 13 years, and I’ve owned four greenfield Android builds end to end. This is what I ask on a first call, what helps if you bring it, and what I’d ask if I were the one hiring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bring two things
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A one-paragraph problem.&lt;/strong&gt; Who the app is for, and what it fixes for them, in plain words. “Households that employ domestic staff lose track of attendance, advances and half-days, so the monthly salary settlement is hard to get right” is a good paragraph. “An app like Uber, but for X” is a starting point, not a problem.&lt;/p&gt;

&lt;p&gt;If you can’t write the paragraph yet, that’s useful to know too. It means the first conversation should be about the problem, not about the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A short, ranked list of must-have features.&lt;/strong&gt; Write down what the app has to do, put the most important thing first, and keep the nice-to-haves on a separate list. The ranking matters more than the length. When time or budget runs short, and it usually does, the ranking decides what ships first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you don’t need: a full spec document
&lt;/h2&gt;

&lt;p&gt;You don’t need a 40-page requirements document before you talk to anyone. Turning your problem and your feature list into a plan is part of the work. When I take on an MVP, we agree what the first version must do and what can wait in a paid discovery sprint, and it ends in a written plan and an estimate you can use with me or with anyone else.&lt;/p&gt;

&lt;p&gt;A good developer will help you write that plan. Be wary of one who skips it and quotes a price straight from your feature list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I’ll ask you
&lt;/h2&gt;

&lt;p&gt;These are the questions I ask on every first call. Expect a good developer to ask something similar.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who are the users?&lt;/strong&gt; Not “everyone”. Who opens the app first, on what kind of phone, and in what situation? A field worker on patchy signal needs a different app from an office team on Wi-Fi.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What’s your budget range?&lt;/strong&gt; A rough range is enough. It isn’t there to set the price. It’s there so the plan fits what you can spend, instead of a plan you then have to cut in half.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What already exists?&lt;/strong&gt; Designs, a website, a backend, an earlier version of the app, store accounts, anything. Each of these changes the plan, sometimes a lot, and it’s far better to know on day one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to ask the developer
&lt;/h2&gt;

&lt;p&gt;Turn the questions around. These are the ones I’d want answered if I were hiring.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who owns the code?&lt;/strong&gt; The answer should be you. In my case, you get read access to the repository from the start so you can see every commit, and the code is transferred to your repository at each paid milestone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When will I see the app on a real phone?&lt;/strong&gt; You should get working builds on a test track every week or two as the app grows, not slides and a big reveal at the end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who handles the store release?&lt;/strong&gt; Getting an app into Google Play or the App Store involves signing, store listings, privacy declarations like Play’s Data Safety form, and ideally a staged rollout. Ask whether that’s included, or whether it’s a surprise at the end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Could someone else take over later?&lt;/strong&gt; You want an app your own team, or another developer, can pick up later, not one only its builder understands. Ask how the decisions get written down.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write the one-paragraph problem.&lt;/li&gt;
&lt;li&gt;Rank your must-have features, and keep the nice-to-haves separate.&lt;/li&gt;
&lt;li&gt;Know your budget range, and list what already exists.&lt;/li&gt;
&lt;li&gt;Skip the spec. Plan it with the developer, and ask who owns the code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you’d like to go through this with me, the &lt;a href="https://abhishekgoyal.me/services/mvp" rel="noopener noreferrer"&gt;MVP page&lt;/a&gt; explains how a build starts with a discovery sprint and what you get at the end of it.&lt;/p&gt;

</description>
      <category>hiring</category>
      <category>startup</category>
      <category>mvp</category>
    </item>
    <item>
      <title>Native, Kotlin Multiplatform or React Native: how I'd choose</title>
      <dc:creator>Abhishek Goyal</dc:creator>
      <pubDate>Fri, 25 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/abhishek_goyal_c97dbf2eea/native-kotlin-multiplatform-or-react-native-how-id-choose-24lf</link>
      <guid>https://dev.to/abhishek_goyal_c97dbf2eea/native-kotlin-multiplatform-or-react-native-how-id-choose-24lf</guid>
      <description>&lt;p&gt;“Should we build this in Kotlin Multiplatform or React Native?” is a question teams often ask before anyone has written down what the app has to do. That’s backwards. I’ve shipped all three approaches to production: native Android and iOS apps, a shared Kotlin Multiplatform data layer in HelperBook, and a React Native rewrite of two native apps at Pulse. The framework has never been the first decision. The features are.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the features, not the framework
&lt;/h2&gt;

&lt;p&gt;Before choosing anything, I list what the app actually has to do, and which of those things lean on the platform. Camera capture, video playback, background work, offline data, sensors, OS integrations. Then I weigh each option against that list, along with the team that will maintain it and the time and money available.&lt;/p&gt;

&lt;p&gt;Most of the heat in “Kotlin Multiplatform vs React Native” debates comes from people comparing the tools in the abstract. For a specific app, the answer is usually much clearer than the debate suggests.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I’d stay fully native
&lt;/h2&gt;

&lt;p&gt;Sharing code is not free, so sometimes the right amount to share is none.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The market is on one platform.&lt;/strong&gt; HelperBook is for Indian households, where Android is the phone most people carry, so it shipped on Android first. Its records still live in a shared Kotlin Multiplatform module with SQLDelight, so an iOS app could reuse that data layer rather than rewrite it, but the app itself is native Android.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The app leans hard on platform APIs.&lt;/strong&gt; On a video-first hiring platform I built 100% of the Android client, and the core of it was CameraX capture, Media3 playback and on-device speech transcription. That is exactly the kind of work where you want the platform’s own APIs directly, not through a layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The two apps look and behave differently.&lt;/strong&gt; If the Android and iOS versions don’t share much UI or flow, a shared UI codebase saves less than it costs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When Kotlin Multiplatform fits
&lt;/h2&gt;

&lt;p&gt;Kotlin Multiplatform shares business logic and data while each platform keeps a fully native UI. I reach for it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native apps already exist.&lt;/strong&gt; You can add a shared module to the apps you have and move code into it gradually, instead of rewriting both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The team writes Kotlin.&lt;/strong&gt; Android developers can own the shared code without learning a new stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The business logic is complex and must match on both platforms.&lt;/strong&gt; Rules, calculations and offline data that behave slightly differently on Android and iOS are a bug factory. Writing them once removes a whole class of problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native feel matters.&lt;/strong&gt; The UI stays Jetpack Compose on Android and SwiftUI or UIKit on iOS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When React Native fits
&lt;/h2&gt;

&lt;p&gt;React Native gives you one codebase for the UI as well as the logic. I recommend it when the timeline is short and the budget is small, and one team has to ship both platforms quickly. It also makes more sense when the screens are the same on both platforms, and when there’s already a React web team whose skills carry over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance, honestly
&lt;/h2&gt;

&lt;p&gt;People search for “Kotlin Multiplatform vs React Native performance” a lot, so here’s my view. Kotlin Multiplatform keeps the UI fully native, and native UI is smoother. For most business apps (forms, lists, dashboards) React Native is fine, and the difference users feel comes far more from the architecture than from the framework.&lt;/p&gt;

&lt;p&gt;I haven’t shipped a Flutter app, so I won’t argue for or against it from experience. It’s a real option; it’s just not one I can speak to first-hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Pulse taught me
&lt;/h2&gt;

&lt;p&gt;At Pulse I owned mobile, Android and iOS, from an early prototype to 100+ B2B enterprise clients. For years it ran as two native apps, Kotlin on Android and Swift on iOS, kept at feature parity. Keeping two native apps at parity meant building every feature twice.&lt;/p&gt;

&lt;p&gt;In 2025 I led the rewrite of both apps into one React Native codebase, architected and co-built with the team in six months, and it replaced both native apps in production. React Native fitted the situation: one codebase suited the timeline and budget, the web app was already built in React, and the screens were the same on both platforms.&lt;/p&gt;

&lt;p&gt;The hardest part of the port was reliable background uploads, which the offline engine depended on. The inspections could run to 500+ questions and 1,000+ photos, captured offline and uploaded in the background once a connection came back, so the upload behaviour had to be as dependable after the rewrite as before it.&lt;/p&gt;

&lt;p&gt;Looking back, React Native was a reasonable call for that timeline, budget and team. Knowing what I know now, I’d weigh Kotlin Multiplatform harder for an app with that much offline logic. The sync rules and the upload queue are exactly the kind of code you want written once and shared, while each platform keeps its own native background APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I’d decide for your app
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If your app…&lt;/th&gt;
&lt;th&gt;I’d lean towards&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Targets one platform first, or leans hard on camera, video, sensors&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already has native apps, or complex logic that must match on both&lt;/td&gt;
&lt;td&gt;Kotlin Multiplatform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Needs both platforms fast, on a small budget, with the same screens&lt;/td&gt;
&lt;td&gt;React Native&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these is a rule. They’re where I start, and the feature list decides the rest. If you’re weighing this for a real app, &lt;a href="https://abhishekgoyal.me/services/kmp" rel="noopener noreferrer"&gt;here’s how I help teams adopt Kotlin Multiplatform&lt;/a&gt;, and every engagement starts with a paid discovery sprint that ends in a written recommendation you can use with anyone.&lt;/p&gt;

</description>
      <category>kotlinmultiplatform</category>
      <category>reactnative</category>
      <category>android</category>
      <category>ios</category>
    </item>
    <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>
