Most Flutter tutorials assume the network is always there: call an API, show a spinner, render the result. That model breaks the moment your users lose signal — and if you're building for regions with unreliable connectivity, offline isn't an edge case, it's the default state you design for.
Here's what I've learned building offline-first apps in Flutter, with the patterns that actually hold up in production.
The instinct is to treat local storage as a fallback: fetch from the API, cache the result, read from cache if offline. Flip that model. Your UI should always read from local storage first, full stop. Syncing with a remote server becomes a background process that updates local storage — it never blocks what the user sees.
// Instead of this:
Future getTasks() async {
if (await hasConnection()) {
return await api.fetchTasks(); // UI waits on network
}
return await localDb.getTasks(); // fallback only
}
// Do this:
Stream watchTasks() {
syncInBackground(); // fire-and-forget, updates local db
return localDb.watchTasks(); // UI always reads local, reactively
}
With this shape, a StreamBuilder watching localDb.watchTasks() renders instantly regardless of connectivity, and updates automatically whenever a background sync writes new data.
Every write — create, update, delete — goes to local storage immediately and gets marked as pending. A background worker (I use workmanager for this) picks up pending writes and pushes them when a connection exists.
class PendingWrite {
final String id;
final String operation; // create, update, delete
final Map<String, dynamic> payload;
final DateTime createdAt;
bool synced = false;
}
The UI never waits on this queue — a task the user just created shows up immediately, marked subtly as "syncing" if you want a visual cue, but fully usable either way.
If the same record gets edited on two devices while offline, something has to give when they reconnect. For most single-user apps, last-write-wins by timestamp is enough:
if (localRecord.updatedAt.isAfter(remoteRecord.updatedAt)) {
// local wins, push to server
} else {
// remote wins, overwrite local
}
But if your app has any collaborative element — shared lists, multi-device editing — retrofitting this later is genuinely painful. Bake a version or updatedAt field into your schema from day one, even if you don't need real conflict resolution yet. It's much cheaper to have it unused than to add it after users already have data in the wild.
Knowing there's a network interface active isn't the same as knowing requests will succeed — captive portals, throttled connections, and DNS issues can all report "connected" while nothing actually works. I wrap sync attempts so failure is expected, not exceptional:
Future trySyncPendingWrites() async {
final pending = await localDb.getPendingWrites();
for (final write in pending) {
try {
await api.push(write).timeout(const Duration(seconds: 8));
await localDb.markSynced(write.id);
} catch (_) {
// stays pending, retried on next sync cycle — no crash, no user-facing error
continue;
}
}
}
If part of your app's content doesn't change per-user — lessons, reference data, onboarding flows — ship it inside the app instead of treating it as an API call. It's one less thing that can fail when connectivity is poor, and it makes first-launch experience instant instead of dependent on a successful fetch.
Putting it together
None of these patterns are exotic — the Flutter ecosystem (Hive or Isar for local storage, Riverpod or Bloc for state, workmanager for background sync) makes this very achievable without reinventing infrastructure. The real shift is mental: design for "no connection" as the default case, and treat connectivity as a bonus feature your app takes advantage of when available, not a dependency it relies on to function.
I'd genuinely like to compare notes — what local storage and state management combo have you settled on for offline-heavy apps, and what conflict resolution approach did you land on?