<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Writing — Abhishek Goyal</title><description>Notes on mobile engineering from Abhishek Goyal: architecture, offline-first sync and lessons from shipping Android and iOS products.</description><link>https://abhishekgoyal.me</link><language>en-in</language><atom:link href="https://abhishekgoyal.me/rss.xml" rel="self" type="application/rss+xml"/><item><title>Native, Kotlin Multiplatform or React Native: how I&apos;d choose</title><link>https://abhishekgoyal.me/writing/native-kmp-or-react-native</link><guid isPermaLink="true">https://abhishekgoyal.me/writing/native-kmp-or-react-native</guid><description>How I choose between native, Kotlin Multiplatform and React Native: features first, then the trade-offs. With what I learned rewriting Pulse.</description><pubDate>Fri, 25 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&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 id=&quot;start-with-the-features-not-the-framework&quot;&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 id=&quot;when-id-stay-fully-native&quot;&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 id=&quot;when-kotlin-multiplatform-fits&quot;&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 id=&quot;when-react-native-fits&quot;&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 id=&quot;performance-honestly&quot;&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 id=&quot;what-pulse-taught-me&quot;&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 id=&quot;how-id-decide-for-your-app&quot;&gt;How I’d decide for your app&lt;/h2&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;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=&quot;https://abhishekgoyal.me/services/kmp&quot;&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;</content:encoded><category>kotlin-multiplatform</category><category>react-native</category><category>android</category><category>ios</category></item><item><title>Offline-first with a server: lessons from Pulse&apos;s sync engine</title><link>https://abhishekgoyal.me/writing/offline-first-sync-lessons</link><guid isPermaLink="true">https://abhishekgoyal.me/writing/offline-first-sync-lessons</guid><description>Offline-first architecture lessons from Pulse&apos;s Android and iOS sync engine: temporary IDs, upload order, conflicts and visible sync status.</description><pubDate>Thu, 24 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The &lt;a href=&quot;https://abhishekgoyal.me/writing/offline-first-without-a-server&quot;&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 id=&quot;records-need-an-identity-before-the-server-has-one&quot;&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;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-high-contrast github-dark-high-contrast&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;// Simplified illustration, not Pulse&amp;#39;s actual code.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;fun&lt;/span&gt;&lt;span&gt; idForUpload&lt;/span&gt;&lt;span&gt;(record: &lt;/span&gt;&lt;span&gt;LocalRecord&lt;/span&gt;&lt;span&gt;): &lt;/span&gt;&lt;span&gt;RecordId&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    record.serverId ?: &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;        val&lt;/span&gt;&lt;span&gt; assigned &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; createRecordOnServer&lt;/span&gt;&lt;span&gt;(record.localId)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;        record.serverId &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; assigned&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;        assigned&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&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 id=&quot;upload-order-is-a-correctness-decision-not-a-performance-one&quot;&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 id=&quot;status-has-to-be-visible-or-people-dont-trust-the-sync&quot;&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 id=&quot;conflicts-are-mostly-designed-away-but-not-entirely&quot;&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 id=&quot;knowing-nothing-is-actually-getting-lost&quot;&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 id=&quot;what-id-tell-another-builder&quot;&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=&quot;https://abhishekgoyal.me/work/pulse&quot;&gt;case study&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>offline-first</category><category>sync</category><category>android</category><category>ios</category></item><item><title>Offline-first with no server: why HelperBook is local-only</title><link>https://abhishekgoyal.me/writing/offline-first-without-a-server</link><guid isPermaLink="true">https://abhishekgoyal.me/writing/offline-first-without-a-server</guid><description>Offline-first Android architecture with no server: why HelperBook, a household payroll app, keeps all its data on the phone, and what that costs.</description><pubDate>Thu, 24 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&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 id=&quot;the-data-is-the-reason-not-to-have-a-server&quot;&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 id=&quot;no-sync-engine-to-design&quot;&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 id=&quot;building-the-honesty-into-the-data-model-not-the-ui&quot;&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;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-high-contrast github-dark-high-contrast&quot; tabindex=&quot;0&quot; data-language=&quot;kotlin&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;// Simplified illustration of the rule, not HelperBook&amp;#39;s actual code.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;fun&lt;/span&gt;&lt;span&gt; attendanceValue&lt;/span&gt;&lt;span&gt;(mark: &lt;/span&gt;&lt;span&gt;DayMark&lt;/span&gt;&lt;span&gt;?): &lt;/span&gt;&lt;span&gt;Double&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; when&lt;/span&gt;&lt;span&gt; (mark) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    DayMark.ABSENT &lt;/span&gt;&lt;span&gt;-&amp;gt;&lt;/span&gt;&lt;span&gt; 0.0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    DayMark.HALF_DAY &lt;/span&gt;&lt;span&gt;-&amp;gt;&lt;/span&gt;&lt;span&gt; 0.5&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    DayMark.PRESENT, &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt; -&amp;gt;&lt;/span&gt;&lt;span&gt; 1.0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&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 id=&quot;release-engineering-without-a-backend-to-hide-behind&quot;&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. After a closed beta with 12–20 households it’s now live on Google Play, built with Jetpack Compose,
min SDK 24, target API 35.&lt;/p&gt;
&lt;h2 id=&quot;what-id-tell-another-builder&quot;&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=&quot;https://abhishekgoyal.me/work/helperbook&quot;&gt;case study&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>offline-first</category><category>kotlin-multiplatform</category><category>privacy</category><category>android</category></item></channel></rss>