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
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);
});
}
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');
}
}
}
}
To trigger a sync, I scheduled a periodic task:
Workmanager().registerPeriodicTask(
'syncTask',
'syncTask',
frequency: Duration(minutes: 15),
initialDelay: Duration(seconds: 10),
);
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');
}
...
}
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);
});
}
And in your push handling:
void onPushReceived(Map<String, dynamic> data) {
if (data['action'] == 'sync_now') {
SyncService().syncPendingTasks();
}
}
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
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)