
The moment offline-first stopped being a buzzword for me was somewhere past Mukono, watching an extension officer try to register a farmer's tomato harvest. Full signal in the trading centre. Two kilometres down a murram road into the actual garden, the bars dropped to a sad little "E", and our shiny app just sat there spinning. The farmer was patient. The officer was not. And I realised the app I'd tested entirely on office WiFi had no idea what to do when the network was technically there but completely useless.
That trip rewired how I build. If you're shipping offline-first mobile apps for real Ugandan users — boda guys, SACCO treasurers, farmers, market vendors — you are not building an online app that handles offline gracefully. You're building an offline app that occasionally gets to talk to a server. Here's what I've learned doing it, mostly the hard way.
The single biggest mindset shift: stop treating the server as your source of truth and the phone as a cache. Flip it. The phone is the source of truth. The server is where you eventually agree with everyone else.
In practice this means every screen reads from local storage and never, ever waits on a network call to render. On Flutter I lean on Drift over SQLite for this — typed queries, reactive streams, and I can watch a table and have the UI update the instant local data changes, sync or no sync.
// UI watches local data. Sync happens somewhere else entirely.
Stream<List<HarvestRecord>> watchHarvests() {
return (select(harvests)
..orderBy([(h) => OrderingTerm.desc(h.createdAt)]))
.watch();
}
The user taps "save", the record lands in SQLite, the list updates, done. Whether that record reaches the cloud in three seconds or three hours is none of the UI's business. The day you internalise that, half your spinner-related bugs disappear.
isConnected is lying to youHere's the trap that got me in Mukono. Plugins like connectivity_plus tell you there's a connection. They do not tell you that connection is worth anything. A phone clinging to 2G in a valley reports "mobile data: connected" right up until your request times out 30 seconds later.
So I stopped trusting the connectivity status as a green light. It's useful as a red light — no interface at all means definitely don't try. But "connected" only means "maybe, let's find out." The real test is a tiny, cheap reachability ping with an aggressive timeout:
Future<bool> canReachServer() async {
try {
final res = await http
.get(Uri.parse('$baseUrl/ping'))
.timeout(const Duration(seconds: 5));
return res.statusCode == 200;
} catch (_) {
return false; // timeout, DNS fail, captive portal — all the same to us
}
}
Five seconds, not thirty. Users in a low-signal area will forgive you for not syncing. They will not forgive a frozen screen. Fail fast and let them keep working.
Once the local DB is your truth, syncing becomes a separate, boring background job: take everything that changed, push it up, pull down what changed elsewhere, resolve disagreements. The trick is doing this so a flaky connection can't corrupt anything.
Two rules saved me here.
Generate IDs on the device, not the server. Every record gets a UUID the instant it's created, offline, before it has ever seen the internet. This sounds small. It's everything. It means a record has a stable identity from birth, so when the network finally comes back and you push it, the server can tell "this is a new thing" from "I've seen this before" — even if the phone died and retried the same push twice.
Make the sync endpoint idempotent. Because mobile networks will drop the response after the server already saved your data. The classic ugly bug: a farmer's loan repayment shows up twice because the app pushed, the network ate the "200 OK", the app assumed failure and pushed again. If your server upserts by that client-generated UUID instead of blindly inserting, the double-push is harmless. Send it five times, you still get one repayment.
I keep a simple outbox table — a queue of pending mutations — and drain it whenever the server is reachable. Each item has a retry count. After a few failures it backs off instead of hammering. Nothing dramatic, just a list of "things I still need to tell the server about."
If your conflict resolution is "last write wins, by timestamp" — and most of ours start there — please use the server's clock, not the device's.
Cheap Android phones in the field have wildly wrong clocks. I've seen devices three days in the future and one stuck in 2019. If two officers edit the same farmer's profile and you decide the winner by device time, the phone with the most broken clock wins every disagreement. That is not a feature.
Stamp the authoritative time when the record hits the server. Use the device timestamp only for ordering things within the same device, where it's at least internally consistent. For genuinely shared records that multiple people touch, last-write-wins is often too crude anyway — track changes at the field level so two people editing different columns of the same row don't clobber each other. It's more work. It's also the difference between users trusting your app and users keeping a paper backup "just in case".
This one is easy to forget from a desk with unlimited fibre. Most of our users buy data in bundles. A 500MB bundle is a real budget line. An app that aggressively syncs in the background, re-downloads images it already has, or polls every 30 seconds is quietly eating people's airtime, and they will notice and uninstall.
So I got stingy on purpose:
updatedAt watermark. Re-downloading the whole farmer list every launch is lazy and expensive.Users want to know their data is safe. They do not want a red error banner screaming at them every time they walk behind a building.
I settled on quiet, honest states. A small label or icon: saved on device, syncing, all synced. "Saved on device" is the important one — it tells the farmer their record is safe right now, before any cloud round-trip, which is exactly the reassurance they need. The internet coming and going becomes a normal background fact, not an alarm. Reserve loud errors for things the user can actually act on, like "this didn't save, please try again." Everything else syncs silently and updates the little label when it's done.
Sometimes there is no data connection at all, not now, not for hours. For a class of actions that genuinely can't wait — a critical alert, a confirmation a farmer needs today — I treat SMS as the floor. USSD and SMS reach phones where my beautiful Flutter UI never will. It's not glamorous, but a feature delivered over SMS beats a feature stuck in an outbox. Mobile money worked out the same lesson years ago: meet people on the channel that actually reaches them.
You won't need this for everything. But knowing which actions are "must arrive" versus "can sit in the queue" is worth deciding up front, not during an incident.
You cannot validate offline-first from a chair with good WiFi. I learned to build a little ritual into testing: airplane mode on, do a bunch of work, airplane mode off, watch it sync. Then the nastier one — turn the network off mid-sync, while a push is in flight, and confirm nothing duplicates or vanishes when it resumes. Throttle to 2G speeds in the emulator. Kill the app while the outbox is draining and reopen it.
The bugs that embarrass you in production almost never show up on a clean connection. They live in the messy middle: half-sent requests, retries, the app reopening with a queue full of pending changes. If you don't go looking for them, your users in Kayunga will find them for you.
Offline-first isn't a feature you bolt on at the end. It's a posture. Treat the phone as the truth, the network as a bonus, every sync as something you'll have to retry, and every megabyte as someone's money. Do that, and the app feels instant whether the user is on fibre in Kampala or one bar of EDGE in a garden somewhere past Mukono.
Your users won't praise you for it — they'll just quietly keep using the thing, in places where most apps give up. Which, when you think about it, is the whole point.