Every mobile developer eventually hits the same wall: the app works perfectly when the user is looking at it.
But the moment they press the home button, everything stops. A sync that should have completed in the background never ran. A notification that should have fired didn't. A file upload that started while the user was in the app failed silently the moment they switched away.
Background execution on mobile is one of the most misunderstood topics in the entire mobile engineering space. Most developers treat it like a simple problem: just keep the code running in the background. The platforms treat it like a resource management problem that directly affects battery life, performance, and the overall health of the device.
Understanding how iOS and Android actually think about background work, and then understanding how Flutter sits on top of both, is what separates engineers who fight the platform from engineers who work with it.
This article covers exactly that. You'll understand the native mechanisms on both platforms, the Flutter packages that bridge them, and when to reach for each approach.
Table of Contents
Prerequisites
This article is written for mobile engineers building or maintaining production applications on iOS, Android, or Flutter. Before reading, you should be comfortable with:
The basic lifecycle of a mobile app: what happens when the app moves between foreground, background, and suspended states
General mobile development concepts: you have shipped or maintained at least one mobile application on any platform or framework
Writing mobile applications in any framework: native iOS, native Android, Flutter, React Native, or any other mobile development stack
Multithreading and concurrency in your programming language of choice: understanding how your language handles work that runs outside the main thread is the foundation of everything this article covers
If you're completely new to mobile development or have never had to think about threads and concurrent execution, start with those fundamentals first and return to this article when you are ready to think about production-grade background work.
Why Background Execution is Hard
When your app is in the foreground, the platform gives it essentially full access to CPU, network, and memory. The user is actively looking at the app. Battery drain is expected. Resource usage is justified.
The moment the app goes to the background, the calculus changes completely. There are potentially dozens of apps installed on the device. If every one of them ran freely in the background, the battery would drain in hours. The CPU would be constantly active. Memory would fill up. The device would become hot and slow.
Both iOS and Android made a decision early on that background execution is a privilege, not a right. Apps must earn the right to run in the background by declaring what they need and why. The platform then decides how, when, and for how long to grant that access.
iOS and Android approached this problem differently but have been converging toward the same model over time: declared, categorized, and constrained background work that the platform controls.
How Flutter Runs in the Background
Before looking at iOS and Android separately, you need to understand something fundamental about how Flutter works.
Flutter runs your Dart code in a single main isolate. This isolate is attached to the platform's main thread. It handles your UI, your business logic, everything. When the app goes to the background, this main isolate can be suspended at any time.
When background work needs to happen in Flutter, the platform doesn't wake your main isolate. It wakes a separate, independent Dart isolate specifically for background execution. This background isolate runs in complete isolation from the main isolate.
What This Means Practically:
The background isolate has no access to the widget tree. You can't call setState, update any UI, or use any widget or BuildContext. It's pure Dart execution with no UI layer.
The background isolate doesn't share memory with the main isolate. Any data you need in the background must be persisted to disk (shared preferences, local database, files) and read by the background isolate independently.
The background isolate must be a top-level function or a static method. It can't be an anonymous function or a method on a class instance. The platform needs to be able to call this function by name when waking the app.
Understanding this changes how you think about background work in Flutter. You aren't keeping your app running. You're registering a separate piece of Dart code that the platform can invoke on its own schedule, in its own isolated environment.
iOS Background Execution
What iOS Does to Your App
iOS manages your app through a set of clearly defined states.
When the user presses the home button, your app moves from foreground to background. iOS gives you a very brief window, typically five to ten seconds, to finish whatever you were doing. After that, your app is suspended. Suspended means completely frozen in memory. No Dart code runs. No network requests go out. The app exists in RAM but is essentially paused.
iOS can kill suspended apps at any time if it needs memory. When the user returns to your app, iOS either resumes it from suspension (fast) or relaunches it from scratch (slow). If your app was killed while suspended, the user won't know. The app just relaunches normally.
To do anything meaningful in the background on iOS, you must declare your intentions. iOS has a specific list of background capabilities, and your app must request exactly the ones it needs. Apple reviews these declarations during App Store submission.
BGTaskScheduler
BGTaskScheduler is the modern iOS API for scheduling background work. Introduced in iOS 13, it replaced older and less reliable approaches. It gives you two types of tasks.
BGAppRefreshTask is for short, periodic background work. Think of it as iOS giving your app a brief wake-up to check for new content and update its state. You get approximately 30 seconds. iOS decides when to run the task based on the user's usage patterns. If the user opens your app every morning at 8am, iOS learns this and tries to run your refresh task just before 8am so content is ready when they arrive.
BGProcessingTask is for longer, heavier work. Database migrations, large file processing, or ML model updates. You get several minutes. These tasks only run when the device is plugged in and ideally on WiFi. You get more time but no guarantees on when the task actually runs.
The rules iOS enforces are strict.
You must declare your task identifiers in Info.plist under BGTaskSchedulerPermittedIdentifiers before the app ships. If the identifier isn't declared there, the task will never run regardless of what your code does.
You must register your task handler before applicationDidFinishLaunching completes. This happens before Flutter even initializes. The workmanager package handles this automatically, but it's important to understand why.
Every task must call setTaskCompleted when it finishes. If you don't call this, iOS marks the task as failed and becomes increasingly reluctant to schedule future tasks.
You should always set an expiration handler. If iOS decides to kill your task early, it calls the expiration handler first, giving you a brief moment to clean up, save state, and mark the task as incomplete so it gets rescheduled.
iOS Background Modes
Beyond BGTaskScheduler, iOS has specific background modes for certain categories of apps. These are declared in Info.plist and enable continuous background execution for very specific purposes.
Audio and AirPlay keeps your app running as long as it's playing audio. The user sees now-playing controls on the lock screen. Podcast apps, music apps, and navigation apps with voice guidance use this. iOS is generous with this mode because the user clearly intends the audio to continue.
Location Updates allows continuous GPS access even when backgrounded. There are two levels. Significant location changes uses cell tower data and is battery-friendly. It fires when the device moves significantly, roughly 500 meters. Continuous location updates give precise GPS but drain battery. Apple scrutinizes location background mode during review. You need a genuine, user-facing reason.
Background Fetch is a legacy mechanism where iOS periodically wakes your app for a short window to fetch content. Unlike BGAppRefreshTask, this uses the older API. Most new apps should prefer BGTaskScheduler.
Remote Notifications with the content-available flag allows your server to trigger a brief background wake. When your server sends a silent push notification, iOS wakes your app to process it. This is how many apps stay current without constant polling.
The iOS Reality
iOS background execution is fundamentally about trust. Apple trusts your app with background time if you declare what you need, use it for the declared purpose, and respect the time limits.
Exceed your time limit and iOS terminates your app. Request background modes you don't actually need and App Store review will flag it. Use location in the background for purposes not evident to the user and you will face rejection.
The watchdog timer is real. iOS monitors background tasks actively. Tasks that run too long, use too much CPU, or behave unexpectedly get terminated. Build your background tasks to be fast, focused, and respectful of system resources.
Android Background Execution
What Android Does to Your App
Android manages process priority through a hierarchy. Foreground apps get the highest priority. Apps with running services get elevated priority. Background apps have lower priority. Empty processes and apps with no active components have the lowest priority.
When the system needs memory, it kills processes in order of priority, starting from the lowest. Your background app can be killed at any time. An app with a foreground service is much harder to kill. An active foreground app is essentially never killed by the system.
Android was historically more permissive than iOS. Early Android allowed apps to run services indefinitely in the background. This freedom was abused. Apps ran constantly even when the user hadn't interacted with them in weeks. Battery life suffered, and Android had to respond.
Starting with Android 8.0 Oreo, Google began restricting background services. Apps can no longer start background services when the app itself isn't in the foreground. Each subsequent Android version has tightened these restrictions further. Android is converging toward iOS's model of declared, constrained background work.
Foreground Services
A foreground service is the most reliable form of background execution on Android. It runs continuously and must display a persistent notification. The notification is mandatory. It's how Android communicates to the user that something is actively happening. The user can see it, expand it for details, and stop it if they choose.
Music players show the currently playing track with playback controls. Navigation apps show the current route with estimated arrival time. File upload apps show a progress bar. Fitness apps show elapsed time and current stats.
Android 14 introduced foreground service types. You must now declare what kind of foreground service you're running. The types are: mediaPlayback, location, dataSync, camera, microphone, phoneCall, remoteMessaging, shortService, health, and systemExempted. This is Android deliberately moving toward the iOS model of declared categories.
Foreground services are the right choice when the user expects something to be actively happening. Playing music. Navigating. Uploading a file. Anything where there is an ongoing activity that the user initiated and expects to continue.
WorkManager
WorkManager is Google's recommended solution for deferrable, guaranteed background work. The key characteristics that define it are important to understand.
Guaranteed means your work will eventually run. Even if the app exits, the user restarts the device, or the system kills your process, WorkManager persists the task to a local database and retries it when conditions allow. This is fundamentally different from a background service that disappears if the app is killed.
Deferrable means you don't control exactly when the work runs. You define constraints and WorkManager waits until those constraints are satisfied. Constraints can include requiring network connectivity, requiring the device to be charging, or requiring the battery to not be low. WorkManager picks the optimal time within those constraints.
Periodic tasks have a minimum interval of 15 minutes. This is enforced by the platform, not WorkManager. Android doesn't allow apps to schedule work more frequently than this to prevent battery abuse.
Under the hood, WorkManager uses JobScheduler on modern Android. It manages the complexity of backward compatibility and constraint handling for you.
WorkManager is the right choice for: syncing data with a server, uploading logs or analytics, processing downloaded files, cleaning up old cache entries, generating thumbnails, or sending queued messages.
WorkManager is the wrong choice for anything that needs to run immediately, at an exact time, or continuously.
Doze Mode and App Standby
Doze Mode activates when the device is unplugged, stationary, and the screen has been off for an extended period. In Doze, Android suspends network access, defers WorkManager tasks, ignores wake locks, and defers alarms. The system enters this state to conserve battery when the device is clearly not being used.
The system exits Doze periodically for maintenance windows during which deferred work can run. These windows become less frequent the longer the device stays in Doze.
Only high-priority Firebase Cloud Messaging notifications can break through Doze. This is why server-triggered background refresh is so powerful: your server sends a high-priority FCM message, Android wakes the app even in Doze to process it.
App Standby Buckets categorize your app based on how recently and frequently the user has interacted with it. The buckets are Active, Working Set, Frequent, Rare, and Restricted. The bucket your app is in directly affects how much background work it is allowed to do.
Active means the user used your app very recently. Full background execution allowed.
Working Set means the user uses your app regularly. Slight restrictions on how frequently background work can run.
Frequent means the user uses your app often but not daily. More restrictions.
Rare means the user barely uses your app. Significant restrictions. WorkManager tasks get delayed substantially.
Restricted means the app has been flagged for bad behavior or is almost never used. Background work is heavily throttled. The user or the system has effectively put your app on notice.
If your app ends up in the Rare or Restricted bucket, background sync becomes unreliable. The way to avoid this is straightforward: build an app people actually use regularly.
Flutter Implementation
Now let's look at how Flutter engineers implement background work using the native mechanisms above.
Setup: Project Structure
Background work in Flutter requires coordination between your Dart code and the native platform. The packages handle most of this, but there are configuration steps on both the iOS and Android sides that you must complete for the work to actually run.
workmanager
The workmanager package is the most widely used solution for deferrable background tasks in Flutter. It wraps Android WorkManager and iOS BGTaskScheduler.
Add the dependency:
dependencies:
workmanager: ^0.5.2
On Android, no additional configuration is needed beyond the dependency. WorkManager is part of AndroidX and is available on all modern Android devices.
On iOS, add your task identifiers to Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.yourapp.syncTask</string>
<string>com.yourapp.cleanupTask</string>
</array>
Also add Background Modes capability in Xcode and enable Background fetch and Background processing.
The background callback must be a top-level function. It can't be inside a class:
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((taskName, inputData) async {
switch (taskName) {
case 'syncUserData':
await syncUserData(inputData);
break;
case 'cleanupOldFiles':
await cleanupOldFiles();
break;
default:
print('Unknown task: $taskName');
}
return Future.value(true);
});
}
The @pragma('vm:entry-point') annotation is critical. Without it, the Dart tree shaker may remove this function during release builds because it appears to be uncalled from Dart code. The platform calls it by name, not through Dart, so the tree shaker can't detect the reference.
Returning true from the task tells WorkManager the task succeeded. Returning false tells it the task failed and should be retried.
Initialize workmanager in your main function:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Workmanager().initialize(
callbackDispatcher,
isInDebugMode: kDebugMode,
);
runApp(const MyApp());
}
isInDebugMode: true logs detailed information about task scheduling and execution. Turn this off in production.
Registering a one-time task:
Future<void> scheduleDataSync() async {
await Workmanager().registerOneOffTask(
'syncUserData',
'syncUserData',
initialDelay: const Duration(minutes: 5),
constraints: Constraints(
networkType: NetworkType.connected,
requiresBatteryNotLow: true,
),
inputData: {
'userId': currentUser.id,
'syncType': 'full',
},
);
}
The task will run once, after a minimum 5-minute delay, only when the device has network connectivity and the battery isn't low. The inputData map is passed to the callback and available as the inputData parameter in executeTask.
Registering a periodic task:
Future<void> schedulePeriodicSync() async {
await Workmanager().registerPeriodicTask(
'periodicSync',
'syncUserData',
frequency: const Duration(hours: 1),
constraints: Constraints(
networkType: NetworkType.connected,
),
);
}
The minimum frequency is 15 minutes enforced by the platform. If you set a shorter interval, it gets rounded up to 15 minutes. On iOS, BGTaskScheduler controls the actual timing and may run the task less frequently based on device conditions.
Cancelling tasks:
// cancel one specific task
await Workmanager().cancelByUniqueName('periodicSync');
// cancel all registered tasks
await Workmanager().cancelAll();
flutter_background_service
The workmanager package is great for deferrable work, but sometimes you need something that runs continuously, like a health monitor, a real-time data collector, or a persistent connection.
For that, flutter_background_service creates a long-running service. On Android, this becomes a Foreground Service with a persistent notification. On iOS, it uses a combination of background modes.
Add the dependency:
dependencies:
flutter_background_service: ^5.0.5
flutter_local_notifications: ^17.0.0
The background service entry point, again, must be a top-level function:
@pragma('vm:entry-point')
void onStart(ServiceInstance service) async {
DartPluginRegistrant.ensureInitialized();
if (service is AndroidServiceInstance) {
service.on('setAsForeground').listen((event) {
service.setAsForegroundService();
});
service.on('setAsBackground').listen((event) {
service.setAsBackgroundService();
});
}
service.on('stopService').listen((event) {
service.stopSelf();
});
// your actual background work runs here
Timer.periodic(const Duration(seconds: 30), (timer) async {
if (service is AndroidServiceInstance) {
if (await service.isForegroundService()) {
service.setForegroundNotificationInfo(
title: 'App is running',
content: 'Last sync: ${DateTime.now()}',
);
}
}
// do the actual work
await performBackgroundSync();
// send data to the main isolate if needed
service.invoke('update', {
'lastSync': DateTime.now().toIso8601String(),
});
});
}
Initialize the service:
Future<void> initializeBackgroundService() async {
final service = FlutterBackgroundService();
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'background_service',
'Background Service',
description: 'This channel is used for the background service notification',
importance: Importance.low,
);
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: onStart,
autoStart: true,
isForegroundMode: true,
notificationChannelId: 'background_service',
initialNotificationTitle: 'App Running',
initialNotificationContent: 'Background sync active',
foregroundServiceNotificationId: 888,
),
iosConfiguration: IosConfiguration(
autoStart: true,
onForeground: onStart,
onBackground: onIosBackground,
),
);
await service.startService();
}
Communicating between the background service and your UI:
// in your widget, listen for updates from the background service
class HomeScreen extends StatefulWidget {
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
String lastSync = 'Never';
@override
void initState() {
super.initState();
FlutterBackgroundService().on('update').listen((event) {
setState(() {
lastSync = event?['lastSync'] ?? 'Unknown';
});
});
}
void stopService() {
FlutterBackgroundService().invoke('stopService');
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Text('Last sync: $lastSync'),
ElevatedButton(
onPressed: stopService,
child: const Text('Stop Background Service'),
),
],
),
);
}
}
The invoke and on methods create a two-way communication channel between the background isolate and the main isolate. The background service invokes events with data. The UI listens for those events and updates accordingly.
background_fetch
For simpler periodic background work where workmanager's full constraint system is more than you need, background_fetch provides a cleaner API.
dependencies:
background_fetch: ^1.2.1
void main() {
runApp(const MyApp());
BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask);
}
@pragma('vm:entry-point')
void backgroundFetchHeadlessTask(HeadlessTask task) async {
String taskId = task.taskId;
bool isTimeout = task.timeout;
if (isTimeout) {
BackgroundFetch.finish(taskId);
return;
}
await performQuickSync();
BackgroundFetch.finish(taskId);
}
Configure and start:
Future<void> configureBackgroundFetch() async {
await BackgroundFetch.configure(
BackgroundFetchConfig(
minimumFetchInterval: 15,
stopOnTerminate: false,
enableHeadless: true,
requiresBatteryNotLow: false,
requiresCharging: false,
requiresStorageNotLow: false,
requiresDeviceIdle: false,
requiredNetworkType: NetworkType.ANY,
),
(taskId) async {
await performQuickSync();
BackgroundFetch.finish(taskId);
},
(taskId) async {
// timeout handler
BackgroundFetch.finish(taskId);
},
);
}
Calling BackgroundFetch.finish(taskId) is mandatory. On iOS, failing to call finish tells the platform your task did not complete correctly, which affects future scheduling. On Android, it signals WorkManager that the task is done.
Persisting Data Between Isolates
Since the background isolate and the main isolate don't share memory, data must be persisted to disk.
The most common approaches are shared_preferences for simple key-value data and a local database like sqflite or isar for structured data.
// writing from background isolate
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((taskName, inputData) async {
final prefs = await SharedPreferences.getInstance();
// fetch new data
final newData = await fetchFromServer();
// persist for main isolate to read
await prefs.setString('lastSyncData', jsonEncode(newData));
await prefs.setString('lastSyncTime', DateTime.now().toIso8601String());
return Future.value(true);
});
}
// reading in main isolate when app comes to foreground
class HomeScreen extends StatefulWidget {
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
loadLastSyncedData();
}
Future<void> loadLastSyncedData() async {
final prefs = await SharedPreferences.getInstance();
final data = prefs.getString('lastSyncData');
final syncTime = prefs.getString('lastSyncTime');
if (data != null) {
setState(() {
// update your state with the synced data
});
}
}
}
The Decision Framework
Given a background task requirement, here is how to decide which approach to reach for:
Does the user expect something to actively be running, like music playing, navigation running, or a file uploading? Use a Foreground Service via flutter_background_service. The persistent notification isn't just a requirement, it's straightforward communication to the user about what the app is doing.
Does the work need to happen eventually but not necessarily right now? Something like syncing data, uploading logs, processing files, or cleaning the cache. If so, use workmanager. It guarantees the work runs, respects constraints, and survives app restarts and device reboots.
Does the work need to happen on a server-triggered signal? Use Firebase Cloud Messaging with a high-priority silent notification. Your server sends the signal, iOS and Android wake your app, your background isolate handles the work. This breaks through Doze Mode on Android and works with iOS's silent push mechanism.
Does the work need to happen on a simple periodic schedule with minimal constraints? Use background_fetch for a simpler API when workmanager's full constraint system is more than you need.
Does the work need precise timing? Rethink whether it truly needs to happen in the background. If a user sets a reminder for 3pm, a local notification is the right approach. The notification fires at the exact time regardless of whether the app is in the background.
One note about iOS specifically: no Flutter package can work around Apple's restrictions. If you register a BGAppRefreshTask, iOS decides when it runs. If you set constraints on WorkManager, Android decides when they're satisfied. The platform is in control. Your job is to declare what you need clearly, handle it correctly when the platform gives you the window, and build your app to be resilient when the background work runs later than expected.
Conclusion
Background execution on mobile isn't a Flutter problem or a Dart problem. It's a platform problem that Flutter sits on top of.
iOS is restrictive by design. You declare the specific category of background work you need. Apple evaluates whether that use case is legitimate. When granted, the platform gives you controlled, time-limited windows. Exceed those windows and the system terminates your task.
Android started permissive and has been getting stricter with every major version. Foreground Services give reliable continuous execution with a visible notification. WorkManager gives guaranteed deferred execution with constraints. Doze Mode and App Standby Buckets restrict everything else based on device state and user behavior.
Flutter bridges both through packages that map to the native APIs. workmanager covers deferrable work on both platforms. flutter_background_service covers continuous work with a foreground notification. background_fetch covers simple periodic work with a cleaner API.
The engineers who succeed with mobile background work are the ones who understand what the platform is actually doing and design with those constraints in mind. They don't fight the platform. They declare what they need, handle the windows they're given, persist state properly across isolate boundaries, and build their systems to be resilient when background work is delayed or deferred.
That's how background work actually gets done on mobile.
Understanding the core of background processes and app lifecycle in native and hybrid mobile engineering helps you make an informed architectural decision when selecting the task handler needed to run a specific task.
Happy Coding!!