GrowBook works fully offline. No account, no signal, no problem — the local database is the source of truth, always. But some people want their plants on two phones, or shared with a partner who also forgets to water things. So I added cloud sync and family sharing. That's where "offline-first" stopped being an architecture diagram and started being a series of small, uncomfortable decisions.
The setup: every device has its own local database and works alone. Sync is a thing that happens on top — opportunistically, when there's a network — not something the app waits on. The rule I held to was that the app must never block on the network for a core action. You tap "watered," it's saved locally and the UI updates now; the cloud finds out later.
The hard question isn't uploading or downloading. It's: two devices changed the same thing while offline. Who wins?
Every row in GrowBook carries an updatedAt timestamp and an isDeleted flag (soft deletes, so a deletion can sync like any other change instead of a row silently vanishing). When I sync, I compare timestamps and the later write wins. Standard last-write-wins. Simple, predictable, and wrong just often enough to think about.
Here's the case that made me pick a direction on purpose. It's a shared plant. You water it and mark it done on your phone. Your partner, not knowing, waters it too and marks it done on theirs. Two "completed" events, same plant, roughly the same time. What should the app show?
I decided: on sync, download first, and let the remote win on conflicts. My reasoning was specifically about family sharing — if someone else in your household already recorded a completion, that's real information, and clobbering it with your local copy erases something a real person did. In my sync code the comment just says: "remote wins on conflicts (e.g., family member completions)." That one parenthesis is the whole philosophy. It's not that remote is technically more correct — it's that in a shared household, the other person's action is not noise to be overwritten.
The stuff nobody warns you about, in a bulleted heap because that's how it arrived:
- Deletes are the scary ones. Without soft deletes, "I deleted this plant" is indistinguishable from "this plant hasn't synced yet," and you either resurrect dead rows or delete live ones. The isDeleted flag exists entirely to make deletion a normal, syncable event.
- Clocks lie. Timestamps assume devices agree on what time it is. They mostly do. "Mostly" is doing a lot of work in that sentence and I know it.
- Family sharing is a permissions problem wearing a sync costume. Who's allowed to see and edit whose plants runs on Supabase row-level security — the rules live in the database, not in my Dart code, so a bug in my app can't hand you someone else's data.
- Offline-first means your happy path is the offline path. The network is the edge case. That inversion is the whole mindset, and it's backwards from how most tutorials teach you to build.
What I keep coming back to: sync isn't a technical feature you add, it's a set of value judgments you encode. "Last write wins" sounds neutral, but choosing whose write wins when two humans in the same house both did the right thing — that's a product decision, and pretending it's just engineering is how apps end up quietly deleting things people care about.
If you've built sync: what's your conflict rule, and did you pick it deliberately or inherit it from whatever the library did by default? I picked mine, and I'm still not sure it's right — only that it's mine.
— building GrowBook in public, #5
Top comments (0)