← Back to blog
Offline-First Flutter · Part 10 of 12
October 6, 202611 min read

Offline-First Flutter, Part 10: Background Sync

FlutterDartOffline-First

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's ProviderScope, 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: connected is 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 inside executeTask (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.