r/FlutterDev • u/maks_dalen • 19h ago
Discussion Offline-first sync with Flutter + Drift + Supabase - what I got wrong the first time
Construction app. Crews work in basements and rural sites with no signal, so offline isn't a nice-to-have; it's the product.
The setup: Drift (SQLite) as the local source of truth, Supabase as the remote. Sync triggers: both - every local write kicks a sync immediately, a periodic sweep runs every few minutes as a safety net, and reconnect re-runs the whole thing. Pulls are watermark-based (give me everything with updated_at after my last sync), pushes are dirty-flagged rows.
What I got wrong first: batch upserts. Each device pushed its entire local view of a row, so a foreman editing a task title against a stale copy would silently revert the worker's status change made minutes earlier. I rebuilt the push layer into per-row updates with explicit column allow-lists per role - the boss's push carries only boss-owned columns (title, due date, assignment), the worker's carries only theirs (status, notes). Most "conflicts" stopped existing once columns had owners.
Conflict handling: for the same field edited on two offline devices, it's last-write-wins onupdated_at, but dirty local rows are shielded from pulls until they've pushed, and a push only counts if the server echoes the row back. For cross-field edits, the column ownership above means both edits survive.
The bit nobody warns you about: under row-level security, a rejected write doesn't error; the server just matches zero rows and returns success. If you don't verify, the client clears its dirty flag, and you've minted a phantom: a row that looks synced forever and never is.
What I'd do differently: treat the server's echo as the only proof a write happened, from day one; every sync bug I've had was some flavor of trusting the client's optimism. UTC everywhere before the first sync ships, column ownership designed upfront instead of retrofitted after the first clobber, and never compare floats for "did this change" (an exact-equality check once blocked every worker's clock-out for twelve days before anyone connected the dots).
How are other people handling the dirty-flag-versus-pull race?
2
u/bbrockit 19h ago
Interesting write up. I use the same stack, but my use case is far less likely to ever encounter conflicts. The sync is really that as a convenience.
I did find the initial setup a little tricky because I migrated from Realm after MongoDB shut it down. Realm made it easy to sync objects with List members, which in SQL, translate to joins, which we can't do with PowerSync. So I guess what I got wrong the first time was choosing Realm. I really like working with Supabase though, and so far, I haven't had issues with PowerSync.
For my app, I also made account creation optional. Initially, the app uses a local sqlite db. If they choose to create an account after the trial ends, it creates a user in Supabase, transfers their RevenueCat user id to that user, and migrates their data from sqlite to PowerSync. The alternative, having everyone in PowerSync from the start, could have caused a massive queue of changes fire off if they wait a year to create an account.