· 5 min read
Offline-First Mobile Sync: Use Triggers, Not Timers
A practical pattern for offline-first mobile apps that treats the device as the source of truth and syncs on app open and after key actions, not on a timer.
An offline-first mobile app has one hard question that the name hides: when do you sync? Storing data on the device is the easy half. Deciding the moment the phone talks to the server is where most of the bugs live. I work on an IoT mobile app that keeps all user data on the device and syncs only at a few fixed moments: when the app opens and after certain actions. This post explains that pattern in general terms, and why I now prefer it to background timers.
Why IoT apps push you to offline-first
An IoT app is used next to a device, not next to a router. That can be a basement, a garage, a field or a room where the phone has joined the device's own network and has no internet at all. The user still expects every screen to work.
So the rule is simple: the screen never waits for the network. Every read comes from local storage. Every write goes to local storage first and shows up in the UI at once.
That makes the local database the source of truth for the UI. The server becomes a backup and a way to move data between phones. It is a useful mental flip: you stop asking "is the data loaded yet?" and start asking "has this change been sent yet?".
Both Android and Flutter describe the same idea in their architecture guides. The Android guide on building an offline-first app is a good reference even if you do not write Kotlin.
The real question: when to sync
Once writes land locally, you have a few choices for sending them on:
- On a timer. Every N minutes, push and pull.
- On every change. Each write fires a request.
- On triggers. Sync at a short list of known moments.
Timers look safe but behave badly on phones. The OS decides when background work really runs, so "every 15 minutes" is a wish, not a schedule. A timer also wakes the radio when nothing has changed, and it can fire in the middle of something delicate, like a session with a device.
Syncing on every change is chatty. One screen with ten fields becomes ten requests, and half of them fail when the signal is weak.
Trigger-based sync is the option I trust most. You pick the moments, so you can reason about them and test them.
Trigger-based sync in an offline-first mobile app
The triggers I mean are plain:
- App open. The user just picked up the phone. Pull what changed elsewhere, push what is waiting.
- After certain actions. Not every write. Only the ones where the user would be upset to lose the data, or where another person or phone needs to see it soon.
Everything else waits for the next trigger. A renamed label can wait until the next app open. Nobody is harmed.
To make this work, each write also records that it still needs to go out. A common way is an outbox table next to your data:
CREATE TABLE outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity TEXT NOT NULL, -- e.g. 'device_settings'
entity_id TEXT NOT NULL,
op TEXT NOT NULL, -- 'upsert' or 'delete'
payload TEXT NOT NULL, -- JSON
created_at INTEGER NOT NULL
);
The data change and the outbox row go in one transaction. Either both exist or neither does. That one rule removes a whole class of "saved locally but never sent" bugs.
The sync itself is then one function with one job. Here is a sketch in Dart. The idea is not tied to any framework:
class SyncService {
Future<void>? _running;
/// Safe to call from any trigger. Calls made while a sync
/// is running share that run instead of starting a new one.
Future<void> sync() => _running ??= _run().whenComplete(() => _running = null);
Future<void> _run() async {
if (!await network.isOnline) return;
// 1. Push: oldest first, stop at the first failure.
for (final item in await outbox.pending()) {
await api.send(item);
await outbox.remove(item.id);
}
// 2. Pull: only what changed since the last good sync.
final changes = await api.changesSince(await meta.lastSyncedAt());
await db.transaction(() async {
await store.apply(changes.items);
await meta.setLastSyncedAt(changes.serverTime);
});
}
}
The triggers stay tiny. App lifecycle "resumed" calls sync(). The handful of important actions call sync() after their local write. If the call fails, nothing is lost: the outbox row is still there, and the next trigger tries again.
What goes wrong, and how to guard against it
Double sends. A request can reach the server and the reply can still get lost. The client retries, and the server sees the same change twice. Give every outbox item a unique ID and let the server ignore IDs it has already applied.
Overlapping syncs. App open and an action can fire within the same second. The _running guard above makes them share one run.
Order. Push before pull. If you pull first, fresh server data can overwrite a local edit that has not been sent yet.
Conflicts. Two phones edit the same thing while offline. Pick a rule per entity and write it down. "Last write wins" using a server timestamp is fine for many settings. For anything where both edits matter, keep both and merge on the server.
Third-party SDKs. IoT apps often include SDKs from device providers, and each one has its own storage and its own idea of when to talk to the network. Keep them behind your own interfaces and copy what you need into your own database. Then your sync deals with one model, not five. I used the same idea for ads in Swapping Ad Providers in Flutter Without Touching the UI.
Invisible state. Users forgive "not sent yet". They do not forgive silence. Show a small pending marker while the outbox is not empty, and a clear message when a sync fails for a reason they can fix, such as an expired login.
How to test it
Trigger-based sync is easy to test because the triggers are explicit:
- Turn on airplane mode, make several changes, kill the app, open it online. Everything should arrive once.
- Cut the network in the middle of a push. The remaining items should stay in the outbox.
- Make the same edit on two phones offline, then bring both online. Check the result matches your written rule.
- Fire app open and an action together. You should see one sync run in the logs.
Takeaways
- In an offline-first mobile app, the local database is the source of truth for the UI. The screen never waits for the network.
- Decide when to sync on purpose. App open plus a short list of important actions covers most needs and is easy to reason about.
- Write the data change and its outbox row in one transaction.
- Push before pull, make sends safe to repeat, and allow only one sync at a time.
- Show pending and failed states. Quiet data loss costs more trust than a visible "not sent yet".