← All posts

7 min read

Background Work in Flutter: What iOS and Android Actually Allow

Background work in Flutter, honestly explained — workmanager, iOS background fetch limits, Android Doze, silent push, foreground services and isolates.

  • Flutter
  • Background Tasks
  • Android
  • iOS
Cover illustration for Background Work in Flutter: What iOS and Android Actually Allow

“Can the app sync every five minutes in the background?” is one of the most common questions I get from clients. The honest answer is usually “not the way you’re imagining”, and it’s better to have that conversation before the feature is designed than after it’s built.

Mobile operating systems are aggressive about background work, because background work drains batteries. iOS in particular treats your app’s background time as a favor, not a right. Android is more flexible but has spent years tightening the rules too.

This post covers what you can realistically do in Flutter, which tool fits which job, and how to test any of it.

First, separate two very different problems

People say “background” for two things that have almost nothing in common:

  1. Heavy work while the app is open — parsing a large JSON file, resizing images, encrypting data. The app is in the foreground; you just don’t want to freeze the UI.
  2. Work while the app is not in use — syncing data, uploading a queued file, refreshing content, tracking location. The app may be suspended or not running at all.

The first is entirely under your control. The second is under the operating system’s control, and you’re asking permission.

Heavy work while the app is open: isolates

Dart runs your UI on a single thread. If you do 300 ms of CPU work there, you drop frames. The fix is to move that work to another isolate.

Isolate.run is the simplest option in Dart 3:

final products = await Isolate.run(() {
  final decoded = jsonDecode(rawJson) as List<dynamic>;
  return decoded
      .map((e) => Product.fromJson(e as Map<String, dynamic>))
      .toList();
});

Flutter’s compute(fn, message) does the same thing and also works on the web (where it runs on the main thread, since web doesn’t support isolates the same way).

Two things to remember: data passed between isolates is copied (except for the result, which Isolate.run can transfer efficiently), and isolates are for CPU work. Waiting on a network request doesn’t block the UI — await already handles that.

None of this keeps running when the user leaves the app. For that, you need the platform.

Platform reality check

Here’s what each OS actually does with background requests.

Android has WorkManager, which schedules deferrable work that survives app restarts and reboots. But:

  • Periodic work has a minimum interval of 15 minutes. Ask for less and you get 15.
  • Doze mode and App Standby Buckets batch and delay work when the device is idle or your app is rarely used. “Every 15 minutes” can become “a few times a day”.
  • Some manufacturers add their own battery optimizations on top, which can delay or kill work further.

iOS offers BGTaskScheduler, which runs app refresh and processing tasks when the system decides to. It considers battery, network, how often the user opens your app, and more. You can request an earliest start time; you cannot request a schedule. If the user force-quits your app from the app switcher, background tasks generally stop until they open it again.

Put simply: on both platforms, scheduled background work is opportunistic. Design features so they’re better when it runs, but never broken when it doesn’t.

Periodic work with the workmanager package

The workmanager plugin wraps Android WorkManager and iOS background tasks behind one Dart API. The callback runs in a separate background isolate, so it must be a top-level (or static) function marked as an entry point — otherwise release builds may tree-shake it away:

@pragma('vm:entry-point')
void callbackDispatcher() {
  Workmanager().executeTask((task, inputData) async {
    switch (task) {
      case 'sync-outbox':
        final ok = await OutboxSync.runOnce();
        return ok; // false asks the OS to retry later
    }
    return true;
  });
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Workmanager().initialize(callbackDispatcher);

  await Workmanager().registerPeriodicTask(
    'sync-outbox-periodic', // unique name
    'sync-outbox',          // task name passed to executeTask
    frequency: const Duration(hours: 1),
    constraints: Constraints(networkType: NetworkType.connected),
  );

  runApp(const MyApp());
}

Things that bite people:

  • The background isolate is a fresh start. No providers, no singletons from main, no logged-in state in memory. If you use Firebase, call Firebase.initializeApp() inside the task. Read tokens from secure storage again.
  • Keep tasks short and idempotent. The OS can kill a task mid-way; running it twice must be safe. The outbox pattern from offline-first Flutter apps fits this perfectly.
  • iOS needs native setup. You have to enable the Background Modes capability and list your task identifiers in Info.plist under BGTaskSchedulerPermittedIdentifiers. Follow the plugin’s iOS setup guide carefully; it’s the part most often missed.
  • Use constraints. Requiring a network connection (or charging, for heavy work) means the OS runs your task when it’s actually likely to succeed.

Often the better answer: push

If the real goal is “the app should have fresh data when something changes on the server”, polling in the background is the wrong tool. Let the server tell the app.

With Firebase Cloud Messaging you can send data-only messages (on iOS, these are background notifications with content-available). They can wake the app briefly to fetch new data:

@pragma('vm:entry-point')
Future<void> onBackgroundMessage(RemoteMessage message) async {
  await Firebase.initializeApp();
  if (message.data['type'] == 'orders-updated') {
    await OrdersRepository.refreshCache();
  }
}

// in main(), before runApp:
FirebaseMessaging.onBackgroundMessage(onBackgroundMessage);

The same caveats apply: iOS throttles background notifications and may not deliver them at all if the app was force-quit or the device is in Low Power Mode. Android delivers normal-priority data messages with some delay under Doze; high-priority messages are meant for things the user needs to see. Treat silent push as “refresh soon”, not “guaranteed delivery”, and always refresh again when the app comes to the foreground.

I go deeper on setup and pitfalls in push notifications in Flutter with FCM.

Continuous work: foreground services and background location

Some features genuinely need to keep running: navigation, workout tracking, live delivery tracking. These don’t fit “opportunistic” at all.

On Android, the answer is a foreground service: a service with a persistent notification that tells the user the app is still doing something. Recent Android versions require you to declare a foreground service type (such as location) in the manifest and request the matching permissions. Location packages like geolocator can start one for you via their Android-specific settings.

On iOS, background location uses the location Background Mode plus the “Always” or “While Using” permission with background updates enabled. Apple reviews this closely — your app must clearly need it, and your Info.plist usage descriptions must explain why.

Both platforms make continuous background work visible to the user, on purpose. If you can’t explain to a user why the app needs it, App Review and Play policy reviewers will ask the same question. I covered the live-tracking side of this in Google Maps live location tracking in Flutter.

Choosing the right tool

My decision process is usually:

  • CPU-heavy, app open? Isolate.run / compute.
  • Server has new data? Push (data messages), plus refresh on app resume.
  • Queued uploads or periodic housekeeping? workmanager with constraints, and accept that timing is approximate.
  • User-visible, continuous activity? Foreground service on Android, the appropriate Background Mode on iOS.
  • “Exactly every N minutes, always”? Push back on the requirement. It isn’t available on iOS, and on Android it costs battery and policy headaches.

Testing background work

Background code is hard to test because you can’t just wait for the OS. Some tools help:

  • Keep the logic testable. The task callback should be a thin wrapper around a plain Dart function (OutboxSync.runOnce()) that you unit test like anything else.
  • Android: adb shell dumpsys jobscheduler shows scheduled jobs, and you can force idle mode with adb shell dumpsys deviceidle force-idle to see how Doze affects you. Android Studio’s Background Task Inspector shows WorkManager jobs and their state.
  • iOS: while paused in the Xcode debugger, Apple documents a command to simulate launching a registered task:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"your.task.identifier"]
  • Test the cold path. Kill the app, trigger the work, and confirm the background isolate initializes everything it needs on its own. This is where most “works in debug, does nothing in production” bugs live.

Takeaways

  • Isolates fix UI freezes while the app is open; they are not background execution.
  • Scheduled background work on iOS and Android is opportunistic — design for it to be late or skipped.
  • workmanager needs a top-level @pragma('vm:entry-point') callback, and Android periodic tasks run no more often than every 15 minutes.
  • Prefer push (data messages) over polling when the server knows something changed, and refresh on resume anyway.
  • Continuous work requires a visible foreground service or the right iOS Background Mode, and a clear reason.

If you’re planning a feature that depends on background work and want a realistic read on what the platforms will allow, get in touch.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).