Background Sync
This is Part 10 of the Offline-First Flutter series. Our sync engine is complete and runs while the app is open (triggered by connectivity). But users close apps. They write notes offline on the train, lock the phone, and only reopen the app that evening — and they'd love their notes already synced by then.
That's background sync: running the sync engine when your app isn't in the foreground. It's powerful and genuinely useful — and it comes with hard platform realities you must respect:
Background execution on mobile is a privilege the OS grants, not a timer you control. iOS and Android aggressively limit background work to protect battery. You can request periodic sync, but the OS decides if and when it actually runs. Build for best-effort, opportunistic background sync — never assume "every 15 minutes, exactly."
Let's add it correctly.
Stack:
workmanager+ Drift + Riverpod, Flutter 3.38 / Dart 3.12. Builds on the full sync engine (Parts 6–8).
The analogy: the night-shift cleaner
Analogy — the office night-shift. During the day, you tidy your own desk as you work (foreground sync — you're there, it's immediate). At night, a cleaning crew comes through. You don't control exactly when they arrive, and on a busy night they might skip your floor — but most nights they tidy up while you're gone, so you arrive to a clean office. Background sync is that night crew: opportunistic, OS-scheduled, best-effort — a bonus on top of foreground sync, not a replacement for it.
Two kinds of triggers
Your sync engine (syncNow()) is the same; what differs is what triggers it:
| Trigger | When | Reliability |
| --- | --- | --- |
| Foreground: connectivity (Part 5) | App open, network returns | High |
| Foreground: app resume | App brought back to foreground | High |
| Foreground: manual | User pulls to refresh | High |
| Background: periodic (workmanager) | OS-scheduled, app closed | Best-effort |
| Background: on-reconnect (OS) | Network constraint met while closed | Best-effort |
Foreground triggers are reliable and cover most cases — when the app is open, you sync promptly. Background sync is the safety net for "the user wrote things offline and never reopened the app." Treat it as a bonus that improves freshness, and never let correctness depend on it firing on schedule.
Setting up workmanager
workmanager wraps Android's WorkManager and iOS's BGTaskScheduler behind one Dart API. The critical, non-obvious part: background tasks run in a separate isolate with a fresh memory space — none of your app's running state, providers, or open database exist there. You must re-initialize everything from scratch.
import 'package:workmanager/workmanager.dart';
const syncTaskName = 'fieldnotes-periodic-sync';
// MUST be a top-level or static function, marked as a VM entry point so it
// survives tree-shaking and can be invoked headlessly by the OS.
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((taskName, inputData) async {
// We're in a FRESH isolate: no providers, no open DB. Build them here.
final db = AppDatabase(openConnection());
final api = RestNoteApi(baseUrl: const String.fromEnvironment('API_URL'));
final engine = SyncEngine(db: db, api: api);
try {
await engine.syncNow(); // the SAME push+pull we already built
await db.close();
return true; // success → OS may reschedule
} catch (e) {
await db.close();
return false; // failure → OS may retry with backoff
}
});
}
Why re-create the database/engine inside
executeTask: background tasks run in their own isolate (Dart isolates have separate heaps — no shared memory). Your app'sProviderScope, singletons, and DB connection don't exist here. Forgetting this is the #1 background-sync bug ("it works in the foreground but the background task crashes/does nothing"). Initialize dependencies fresh, do the work, and close the DB before returning.
Register it at app start:
Future<void> initBackgroundSync() async {
await Workmanager().initialize(callbackDispatcher);
await Workmanager().registerPeriodicTask(
'periodic-sync-id',
syncTaskName,
frequency: const Duration(minutes: 15), // Android minimum is ~15 min
constraints: Constraints(
networkType: NetworkType.connected, // only run with a network
requiresBatteryNotLow: true, // be a good citizen
),
existingWorkPolicy: ExistingWorkPolicy.keep, // don't stack duplicates
);
}
networkType: connectedis the OS-level mirror of Part 5. It asks the OS to only run the task when there's a network — saving you from waking up just to find you're offline. (You should still do a reachability check inside, since "connected" isn't "reachable.")
Sync-on-reconnect, even in the background
Beyond periodic, you often want "sync as soon as the network comes back, even if the app is closed." On Android you can register a one-off task with a connectivity constraint that the OS runs when the constraint is met:
await Workmanager().registerOneOffTask(
'reconnect-sync-${DateTime.now().millisecondsSinceEpoch}',
syncTaskName,
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.keep,
);
The OS holds the task until its network constraint is satisfied, then runs it — giving you "sync when reconnected" without your app being open. iOS is more restrictive here (background time is doled out opportunistically via BGTaskScheduler), so on iOS, lean more on foreground sync on app resume. Design so that opening the app always triggers a prompt sync — that's your reliable floor.
Platform realities (read this twice)
Background execution differs sharply by platform, and pretending otherwise leads to bug reports:
| Reality | Android | iOS | | --- | --- | --- | | Minimum periodic interval | ~15 minutes | OS decides; often less frequent | | Exact timing | Not guaranteed (Doze, batching) | Definitely not guaranteed | | May be skipped | Yes (battery saver, Doze) | Frequently (learns usage patterns) | | Killed/force-stopped app | Tasks won't run until reopened | Tasks won't run |
The mantra: background sync is best-effort, foreground sync is your guarantee. The OS may run your periodic task every 15 minutes — or once a day, or not until the user opens the app. Therefore: always sync on app launch/resume, keep the outbox durable so nothing is lost regardless, and treat any background run as a nice-to-have that improves freshness. Never tell users "your data syncs every 15 minutes" — it's a promise the OS won't keep.
Idempotency saves you again
Background and foreground sync can overlap, a periodic task can fire right as the user opens the app, the OS can retry a "failed" task that actually half-succeeded. The defense is the same one we built throughout:
Because
syncNow()is idempotent — outbox push uses UUID idempotency keys, pull uses upsert/delete-by-id with a transactional cursor — it's safe to run concurrently and repeatedly. A background sync overlapping a foreground sync can't corrupt data; at worst there's a little redundant work. This is why the earlier idempotency discipline mattered: it makes background sync safe by construction.
A light guard avoids obvious redundancy (a mutex/flag so two syncs don't run simultaneously in the same isolate), but correctness doesn't depend on it.
Putting the triggers together
The complete trigger picture for FieldNotes:
// 1. App start: sync immediately + register background work.
void onAppStart(WidgetRef ref) {
ref.read(syncEngineProvider).syncNow(); // reliable, prompt
initBackgroundSync(); // best-effort periodic
}
// 2. Connectivity edge (Part 5): sync on reconnect while open.
ref.listen(onlineProvider, (prev, next) {
if (prev?.value == false && next.value == true) {
ref.read(syncEngineProvider).syncNow();
}
});
// 3. App resume: sync when the user comes back.
// (via WidgetsBindingObserver.didChangeAppLifecycleState → resumed)
// 4. Background: workmanager periodic + on-reconnect (best-effort).
Layer the triggers: reliable foreground ones (start, reconnect, resume, manual) guarantee freshness whenever the user is engaged, and best-effort background ones quietly improve it when they're not. Together they make FieldNotes feel "always synced" without ever depending on a single unreliable mechanism.
Practice Challenges
Challenge 1 — The fresh-isolate bug. A dev's background task throws "database not initialized" though it works in the foreground. Why, and the fix?
Show solution
Background tasks run in a separate isolate with no access to the app's providers/singletons/open DB. The fix: initialize the database, API, and sync engine inside executeTask (and close the DB before returning), rather than assuming the foreground instances exist.
Challenge 2 — Best-effort. A PM wants the UI to promise "syncs every 15 minutes." Why push back?
Show solution
Background scheduling is OS-controlled and best-effort — iOS/Android may delay, batch (Doze), or skip the task based on battery and usage. The 15-min "frequency" is a minimum/hint, not a guarantee. Promise instead "syncs automatically when online," and rely on foreground sync for promptness.
Challenge 3 — The constraint. Why set networkType: NetworkType.connected, and why still do a reachability check inside the task?
Show solution
The constraint tells the OS to only run the task when a network exists, avoiding pointless wake-ups while offline. But "connected" ≠ "reachable" (Part 5) — captive portals/down servers — so you still probe your server inside before assuming you can sync.
Challenge 4 — Overlap safety. A periodic background sync fires just as the user opens the app and a foreground sync starts. Why isn't this a data-corruption risk?
Show solution
Because syncNow() is idempotent: push uses UUID idempotency keys and pull uses upsert/delete-by-id with a transactional cursor. Concurrent/overlapping runs can't duplicate or corrupt data — at worst they do a little redundant work (a mutex can avoid even that).
Challenge 5 — iOS reality. Background tasks rarely fire on iOS for a user who seldom reopens the app. How do you keep their data fresh anyway?
Show solution
Make foreground sync on app launch/resume the reliable floor: every time they open the app, syncNow() runs promptly. Keep the outbox durable so nothing offline is ever lost, and treat background runs as a bonus. The user gets fully synced the moment they reopen, regardless of background scheduling.
Questions to test yourself
Q1 (basic). What is background sync and why is it "best-effort"?
Show answer
Running the sync engine while the app is not in the foreground, scheduled by the OS (workmanager → Android WorkManager / iOS BGTaskScheduler). It's best-effort because the OS controls if and when tasks run, delaying/batching/skipping them to protect battery — you can't guarantee timing.
Q2 (basic). Why must you re-initialize the database and dependencies inside the background task?
Show answer
Background tasks run in a separate isolate with its own memory (isolates). The app's providers, singletons, and open DB connection don't exist there, so you must construct them fresh inside executeTask (and close the DB before returning).
Q3 (intermediate). What does @pragma('vm:entry-point') do on callbackDispatcher?
Show answer
It marks the function as a VM entry point so it isn't removed by tree-shaking and can be invoked headlessly by the OS/native side to start the background isolate. Without it, the dispatcher may be stripped from the release build and background tasks won't run.
Q4 (intermediate). Why combine an OS network constraint with an in-task reachability check?
Show answer
The OS constraint (networkType: connected) avoids waking the task when there's no network at all. But "connected" doesn't mean your server is reachable (captive portals, downtime), so an in-task reachability probe (Part 5) prevents wasted sync attempts and confirms it's actually worth proceeding.
Q5 (intermediate). Why is foreground sync the "guarantee" and background sync the "bonus"?
Show answer
Foreground triggers (app start/resume, reconnect, manual) are under your control and fire reliably whenever the user engages. Background scheduling is OS-controlled and may not run. So you guarantee freshness via foreground sync and use background sync only to opportunistically improve it.
Q6 (advanced). Why is the sync engine's idempotency essential specifically for background sync?
Show answer
Background and foreground syncs can overlap, and the OS may retry a task it deemed failed (even if it half-succeeded). Idempotent operations (UUID-keyed upsert push, upsert/delete-by-id pull with a transactional cursor) make running syncNow() concurrently and repeatedly safe — overlaps/retries cause at most redundant work, never duplication or corruption. Without idempotency, background retries would be dangerous.
Wrapping up
- Background sync runs the engine when the app is closed via
workmanager(Android WorkManager / iOS BGTaskScheduler) — but it's best-effort, OS-scheduled, never guaranteed timing. - Background tasks run in a fresh isolate: mark the dispatcher
@pragma('vm:entry-point')and re-initialize the DB/API/engine insideexecuteTask(then close the DB). - Use OS network constraints plus an in-task reachability check; register periodic and on-reconnect tasks.
- Foreground sync (start/resume/reconnect/manual) is your guarantee; background sync is a bonus. Always sync on app launch; never promise a fixed interval.
- The engine's idempotency makes overlapping/retried background runs safe by construction.
In Part 11 we make all of this trustworthy: testing the sync engine — unit-testing the outbox, faking the API and database, and deliberately simulating conflicts and flaky networks so you can ship sync with confidence.