DEV Community

Charlie Fisher
Charlie Fisher

Posted on

The offline-first decision I put off for a year

For a year I told myself my Flutter app didn't need offline support. Then I spent a week in a place with terrible signal and realized how much of the app was pointless without a network.

Adding offline-first after the fact is much harder than starting with it. What I'd do differently:

  • Model every write as an operation that can be queued
  • Give every entity a local id and a server id
  • Decide the conflict rule before writing sync code

We went with "last write wins per field" and it's been fine for our use case, but I regret not writing down the rule earlier.

I'm learning Zig on the side, mostly out of curiosity about low-level memory. It's a very different world from Dart, and that's the point.

Top comments (3)

Collapse
 
lycore profile image
Lycore Development •

Writing the conflict rule down before the sync code is great advice. With last-write-wins per field, how do you handle device clock skew? We've seen phones with wrong clocks win every conflict, so using server-assigned timestamps or a hybrid logical clock helped. Also curious whether your queued operations carry an idempotency key so retries after a flaky connection don't double-apply.

Collapse
 
charlie_tinkers profile image
Charlie Fisher •

Ha, clock skew was exactly our headache, and I'm still not 100% sure we've got it right.

We had a tester whose edits kept "reverting". Couldn't reproduce it for almost two weeks. Turned out their phone clock was about 26 hours ahead, so their last-write-wins kept beating everyone else's later edits. What finally gave it away was our own client-side queued_at — one device was timestamping things in the future.

Since then we don't order by device time at all. Local time is only for display; the server stamps updated_at on arrival and that's what the ordering uses, and the client keeps it so the UI matches the server. It fixed the symptom, but honestly it feels like a patch: a phone can still queue something, go offline for a day, then overwrite newer data when it reconnects. That's the part we haven't really solved.

For idempotency, we added a UUID per queued op after finding duplicate rows from a retry loop on a bad connection. The server has a unique index on (user_id, op_id) and returns the original result on duplicates; the client only clears the op after that response. Knock on wood, no duplicates since.

We haven't tried a hybrid logical clock yet — it's on the list for the next version.

Collapse
 
lycore profile image
Lycore Development •

That 26-hour-ahead phone is a great catch, and server-stamped ordering is the right first fix. For the offline-for-a-day overwrite, one option is to send the last server version (or updated_at) the client saw with each queued op, and have the server flag the op if the row has moved on since then, which turns a silent overwrite into a visible conflict you can merge. Per-field versions keep that from flagging edits to unrelated fields.

⌂ 换网址