Offline-first doesn't mean an app that works offline now and then. It means an app that does its job on the phone first and syncs when a connection shows up. Build it that way and signal stops being something you apologise for.
These are the habits that make it dependable, and the traps that make it feel flaky.
Begin with the worst moment
Picture the signal dying halfway through a save, in the middle of a payment, or with five records still unsent. Design so that moment is boring. Most of the rest follows from that.
Write to the phone first, and keep a queue
Save every change on the device before you touch the network. Instead of syncing raw state, keep a queue of actions: record a sale, confirm a delivery, add a note. Each one is a small record that never changes once it's written. It carries an id made on the phone (a UUID), who did it, when, the details, and a status: waiting, sending, confirmed, or needs attention.
Give every action an idempotency key
Retries are only safe if the server can recognise a repeat. Send the action's id with each request, and let the server ignore ids it has already handled, returning the original answer. Then a dropped response can't create a second sale or a double payout.
Retry like a good neighbour
Wait a little longer after each failure, and add some randomness, so a thousand phones that reconnect at once don't hammer your server together. Keep payloads small. Batch where it helps. Make big uploads, like photos, resumable. And don't poll in a tight loop on someone's data bundle.
Pick conflict rules for each kind of data
One rule won't fit everything. For money and stock, the server decides: apply actions in order, and if one can't be applied, reject it with a reason the person can read and act on. For notes and form fields, merge field by field, or choose last-writer-wins on purpose and show when a value changed. Whatever you pick, never drop someone's work silently. If something has to be thrown away, say so.
Tell the truth about where the data is
People trust an app that's honest with them. Use plain labels like "Saved on this phone", "Sending…", "Confirmed" and "Needs attention", and say why and what to do next. Show how many actions are still queued. Never show a success message for something that only exists on the device, especially when money is involved. Where it makes sense, let people share or export a record, say through a message, so an urgent item isn't stuck behind a bad connection.
Plan for shared phones
In plenty of workplaces, one phone serves several people. Keep each person's queue separate, decide what sign-out does (and don't wipe unsynced work without a warning), and use a short PIN or a similar check instead of leaving a session open for whoever picks the phone up next.
Mind the data budget
Send less. Compact payloads, only what changed, no background polling, static files cached after the first visit. Test on a mid-range Android over a slow, throttled connection, not on your laptop's fibre. Respect the browser's data-saver signal when it's there.
Know where browsers let you down
Support for background sync and similar features varies by browser and operating system, and storage can be limited or cleared when space runs low. So don't make correctness depend on a background task. Sync when the app is open, when it comes back online, and when the user asks. Request persistent storage where it's available, and test on the phones your users actually own.
Try to break it
Switch on airplane mode in the middle of a save. Kill the app while it's sending. Change the phone's clock. Fill the storage. Reconnect with a hundred queued actions. If it recovers without losing or duplicating anything, you're in good shape.
Offline-first matters most where connections and devices are toughest, like agriculture, health and trade. It also goes hand in hand with careful payment handling, which we cover in designing for the M-Pesa callback that never arrives.