DEV Community

Cover image for Debugging Nightmare – Fixing a Background Sync Issue in Flutter
Linwood Matthews
Linwood Matthews

Posted on

Debugging Nightmare – Fixing a Background Sync Issue in Flutter

Introduction: The Sync That Never Happened

You know that moment when everything should just work—but it doesn’t? That was me last week. I had built a feature in Flutter to auto-sync user changes (e.g. offline edits, queued tasks) with a backend server. During development, syncs triggered beautifully while the app was active. But once I locked the phone, switched to another app, or even killed the app, nothing happened. The sync never fired.

Welcome to my debugging nightmare.

In this post, I’ll walk you through my journey: how I diagnosed the issue, what I tried (and failed), and the eventual solution (or partial workaround). You’ll see snippets of real code, logs, and caveats so that if you ever face this, you don’t go in blind.

Background: Why Background Sync Is Hard

Before diving in, here’s some context on why background sync (especially on mobile) is tricky:

  • Mobile OSes aggressively throttle background work (to preserve battery, resources, etc.).
  • On Android, there are restrictions starting with newer versions (Oreo, 10, etc.) on background services and task scheduling.
  • On iOS, background execution is more constrained—most tasks are limited unless you use special APIs (e.g. background fetch, silent push).
  • Flutter runs in a single isolate by default; background tasks require setting up another isolate or using plugins that handle callbacks.
  • Some plugins (Workmanager, background_fetch, etc.) have minimum periodic intervals (e.g. 15 minutes) or depend on OS heuristics.
  • Some Android phone manufacturers (Xiaomi, Huawei, Samsung, etc.) add extra battery optimizations that prevent background tasks unless exemptions are granted.

Flutter’s documentation acknowledges this: running Dart code in background requires using isolates or dedicated plugins.

The Setup: What I Tried First

Here’s a simplified version of my setup:

  • Local storage: Hive (or could be SQLite) for queuing unsynced data
  • Connectivity detection: connectivity_plus
  • Background scheduling plugin: workmanager
  • Sync logic that reads queue, sends to server, marks items as synced

pubspec.yaml (relevant portions)

dependencies:
  flutter:
    sdk: flutter
  hive: ^2.2.0
  hive_flutter: ^1.1.0
  connectivity_plus: ^5.0.0
  workmanager: ^0.5.0
Enter fullscreen mode Exit fullscreen mode

main.dart

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final appDir = await getApplicationDocumentsDirectory();
  Hive.init(appDir.path);
  Hive.registerAdapter(MyTaskAdapter());
  await Hive.openBox<MyTask>('tasks');

  Workmanager().initialize(
    callbackDispatcher,
    isInDebugMode: true,
  );

  runApp(MyApp());
}

@pragma('vm:entry-point')
void callbackDispatcher() {
  Workmanager().executeTask((task, inputData) async {
    print('[bg] callbackDispatcher: executing task $task with $inputData');
    await SyncService().syncPendingTasks();
    return Future.value(true);
  });
}
Enter fullscreen mode Exit fullscreen mode

Sync Service

class SyncService {
  Future<void> syncPendingTasks() async {
    final box = Hive.box<MyTask>('tasks');
    final unsynced = box.values.where((t) => !t.isSynced).toList();
    if (unsynced.isEmpty) {
      print('[bg] no tasks to sync');
      return;
    }
    print('[bg] syncing ${unsynced.length} task(s)');
    for (var t in unsynced) {
      try {
        final res = await ApiClient.sendTask(t);
        if (res.isSuccess) {
          t.isSynced = true;
          t.save();
          print('[bg] task ${t.id} synced');
        } else {
          print('[bg] sync failed for ${t.id}');
        }
      } catch (e) {
        print('[bg] exception syncing ${t.id}: $e');
      }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

To trigger a sync, I scheduled a periodic task:

Workmanager().registerPeriodicTask(
  'syncTask',
  'syncTask',
  frequency: Duration(minutes: 15),
  initialDelay: Duration(seconds: 10),
);
Enter fullscreen mode Exit fullscreen mode

In my UI, when the user submits something offline, I add it to the tasks box and mark isSynced = false.

This worked perfectly when the app was running (foreground). The logs would print, tasks would sync.

The Problem Exposed: Logs Show Nothing

When the app went to background, I never saw the [bg] prints in logs anymore. No “executing task” message. The WorkManager callback never triggered.

I tried:

  • Forcing a shorter interval
  • Using executeOneOffTask instead of periodic
  • Running on multiple devices (emulator, real phone)
  • Checking logs via adb logcat or Flutter debug console

Still, nothing.

One StackOverflow answer hit near my problem: AlarmManager triggers sometimes don’t fire when the phone is idle or locked—Android may throttle them.

Another suggestion: switch to WorkManager (which I was already using).

But others pointed out even WorkManager is unreliable on some devices, especially in “killed” app states.

A Reddit thread raised this exact issue: “The workmanager job doesn't trigger when the internet is connected if the app is in a killed state.”

Yes — in some cases, when the app is “force-closed” or swiped away, Android may not allow any of these scheduled tasks to run unless a foreground service or push-triggered mechanism is used.

Digging Deeper with Logs & Experiments

To get more insights, I:

  • Added a log right when app enters background (via WidgetsBindingObserver)
  • Logged when the OS kills or suspends the isolate
  • Added a foreground service fallback on Android (via platform channel)
  • Tried AndroidAlarmManager (older plugin) as a backup

A snippet for background detection:

class LifecycleWatcher extends StatefulWidget {
  @override
  _LifecycleWatcherState createState() => _LifecycleWatcherState();
}

class _LifecycleWatcherState extends State<LifecycleWatcher>
    with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }
  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    print('[life] new state: $state');
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

I observed:

  • The app does enter AppLifecycleState.paused or inactive, so my widget logs do fire
  • But soon after, the isolate appears to get suspended or terminated
  • My callbackDispatcher never even starts after some time

I also noticed on some devices, unless the app is whitelisted from “battery optimization” or “app sleep,” background tasks never happen.

Solution (or Partial Workaround): Hybrid Approach & Push Triggering

After much frustration, here’s what ultimately worked (or at least worked reliably enough):

1. Use push notifications / silent push to wake the app

If I send a silent push (notification that doesn’t display UI but can wake up background processing), I can trigger the sync logic when connectivity is restored. This is more reliable on both iOS and Android than hoping a periodic task fires. Some apps already use this technique (e.g. chat apps).

(Android may require using FCM with data payload and priority: high).
(This is also suggested in community discussions about unreliable work manager behavior.)

2. Fallback to a foreground service when critical

On Android, if there is something important to sync (e.g. user explicitly pressed “Sync now”), you can start a foreground service (with a persistent notification). That ensures the OS keeps the process alive long enough to finish work.

3. Use Workmanager / periodic tasks only as a “best-effort” extra

Even if it doesn’t always fire, when the app is in a “friendly” state, periodic tasks may still catch up.

4. Ask users to disable battery optimizations (or whitelist the app)

On some phones, the user must grant “unrestricted background activity” or disable “sleep optimization” for the app to work reliably.

Revised callbackDispatcher using push wake or checking “last sync”:

@pragma('vm:entry-point')
void callbackDispatcher() {
  Workmanager().executeTask((task, inputData) async {
    print('[bg] callbackDispatcher: executing $task');

    // We may check timestamp: if last sync was more than X minutes ago
    await SyncService().syncPendingTasks();

    return Future.value(true);
  });
}
Enter fullscreen mode Exit fullscreen mode

And in your push handling:

void onPushReceived(Map<String, dynamic> data) {
  if (data['action'] == 'sync_now') {
    SyncService().syncPendingTasks();
  }
}
Enter fullscreen mode Exit fullscreen mode

Sample Logs of the Fixed Flow

Here’s a hypothetical log after implementing silent push + fallback:

[life] AppLifecycleState.paused  
// user turns off internet, edits item  
[UI] queued task id=42  
(push arrives)  
[FcmHandler] onMessage: {"action":"sync_now"}  
[bg] syncing 1 task(s)  
[bg] task 42 synced  
Enter fullscreen mode Exit fullscreen mode

Now even if the WorkManager periodic call never fired, the push forced the background sync.

Lessons Learned & Caveats

  • Don’t rely solely on periodic tasks — they may never fire on many phones in real-world use
  • Use push / silent push as a primary trigger if you need reliability
  • Foreground services are heavy (user sees a notification), but useful when doing guaranteed work
  • Log everything — pass logs, timestamps, check when the isolate dies
  • Test on many devices — manufacturers’ battery policies differ wildly
  • Be cautious on iOS — background fetch is limited in runtime and frequency
  • Graceful fallback — if background fails, ensure sync happens next time app opens

Also note: some sync libraries (e.g. PowerSync) explicitly show how to run background sync in a WorkManager callback.

Final Thoughts

What began as a simple “auto-sync in background” feature turned into a multi-day debugging odyssey. The culprit wasn’t my logic—it was the mobile OS itself. Once I accepted that periodic scheduling is inherently unreliable in many cases, and shifted to a hybrid approach (silent push + fallback), things became robust.

If you build apps that need background sync, don’t assume the OS will let you fly under the radar. Plan for push wakeups, user permission, logging, fallbacks—and always test under real device conditions (locked, battery saver on, app killed, etc.).

Have an App Idea? Let’s Build a Reliable MVP Together. https://launchwithlinwood.com

Top comments (0)