Every few months a client asks us the same question in slightly different words: "Why does our upload finish on my phone but not on the tester's?" Or "Why does the sync run fine for a day and then just… stop?" Nine times out of ten the answer is the same — the app is doing work in the background that neither Android nor iOS has agreed to let it do.
Background execution is the part of mobile that Flutter cannot abstract away. The framework gives you isolates; the operating systems give you budgets, quotas, and a scheduler that will happily drop your task to save battery. This tutorial is the mental model plus the code we use on engagements: what runs where, which plugin to reach for, how to make a task survive process death, and how to test something that by definition happens when nobody is looking.
Versions here are Flutter 3.4x / Dart 3.x, targeting Android 15–16 and iOS 17–18 behaviour.
First, classify the work
Most background bugs start as a category error. There are only four kinds of "background" on mobile, and they have different rules:
| Kind | Example | Runs when app is killed? | Tool |
|---|---|---|---|
| Off-main-thread compute | Parsing a 12 MB JSON payload, image resize, crypto | No — app must be alive | Isolate.run / compute |
| Deferrable, guaranteed-ish work | Upload queued photos, flush an outbox, refresh a token | Yes, eventually | WorkManager (Android) / BGTaskScheduler (iOS) |
| User-visible ongoing work | Turn-by-turn navigation, run tracking, audio, long file transfer | Yes, while it's visible | Foreground service (Android) / background modes (iOS) |
| Server-initiated wake-ups | "New data available" sync | Yes, at OS discretion | Data/silent push with a background isolate |
Write the category next to the ticket before you write any code. "Upload the user's report when they hit save, even if they background the app" is category 2 with a category 3 fallback — it is not compute.
Category 1: isolates, and the modern API
Isolate.run is the one you want in 2026. It spawns a worker, runs your closure, returns the result, and shuts the isolate down:
final parsed = await Isolate.run(() => _parseLedger(jsonString));
Two rules that catch people:
- Everything you capture must be sendable. Closures capturing a
BuildContext, a Drift database, or a plugin instance will throw at runtime. Capture plain data. - Plugins mostly don't work in a fresh isolate unless you initialise the background messenger (see below). Do the I/O on the main isolate, send bytes to the worker.
For repeated work — a stream of frames, a queue of images — don't spawn per item. Isolate.run costs a few milliseconds of setup each call, which is fine occasionally and terrible in a loop. Use a long-lived worker:
class ThumbnailWorker {
late final SendPort _tx;
final _ready = Completer<void>();
final _pending = <int, Completer<Uint8List>>{};
var _seq = 0;
Future<void> start() async {
final rx = ReceivePort();
await Isolate.spawn(_entry, rx.sendPort);
rx.listen((msg) {
if (msg is SendPort) {
_tx = msg;
_ready.complete();
} else if (msg is (int, Uint8List)) {
_pending.remove(msg.$1)?.complete(msg.$2);
}
});
return _ready.future;
}
Future<Uint8List> resize(Uint8List bytes) {
final id = _seq++;
final c = Completer<Uint8List>();
_pending[id] = c;
_tx.send((id, bytes));
return c.future;
}
static void _entry(SendPort tx) {
final rx = ReceivePort();
tx.send(rx.sendPort);
rx.listen((msg) {
final (id, bytes) = msg as (int, Uint8List);
tx.send((id, _resizeSync(bytes)));
});
}
}
If you're on Flutter 3.4x, also look at Isolate.spawn with TransferableTypedData for large buffers — it moves memory instead of copying it, which matters once payloads pass a megabyte or two.
Category 2: deferrable work that must eventually happen
This is the one people get wrong. You cannot keep a Dart timer running after the user swipes the app away. You hand the work to the OS scheduler and let it decide when.
workmanager remains the pragmatic cross-platform wrapper: it maps to Android WorkManager and to BGTaskScheduler on iOS.
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
// Fresh isolate: nothing from main() has run.
WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();
switch (task) {
case 'flush-outbox':
final db = await openDatabase(); // re-open, don't reuse
final ok = await OutboxSync(db).drain(budget: const Duration(seconds: 20));
await db.close();
return ok; // false => OS retries with backoff
default:
return true;
}
});
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Workmanager().initialize(callbackDispatcher);
runApp(const App());
}
Then enqueue with constraints rather than hope:
await Workmanager().registerOneOffTask(
'flush-outbox-$userId', // unique name = dedupe key
'flush-outbox',
constraints: Constraints(
networkType: NetworkType.connected,
requiresBatteryNotLow: true,
),
existingWorkPolicy: ExistingWorkPolicy.keep,
backoffPolicy: BackoffPolicy.exponential,
initialDelay: const Duration(seconds: 10),
);
Things to internalise before you promise a client a schedule:
@pragma('vm:entry-point')is mandatory. Without it, tree-shaking removes your callback in release builds and the task silently never runs. This is the single most common "works in debug, dead in release" background bug.- The callback runs in a new isolate. No
main(), no providers, no singletons, no open database handle. Re-create everything and tear it down. - Android's minimum periodic interval is 15 minutes, and Doze can stretch it far beyond that. iOS is worse:
BGAppRefreshTaskruns when iOS feels like it, based on usage patterns, and may be hours or never for a rarely-opened app. Never write "syncs every 15 minutes" in a spec. - Return
falseto request a retry. Throwing gets you the same effect but no telemetry. Wrap the body and report failures to Crashlytics/Sentry from inside the isolate, or you'll be debugging blind. - iOS needs declarations: add
BGTaskSchedulerPermittedIdentifiersand theprocessing/fetchbackground modes toInfo.plist, and register identifiers beforeapplication:didFinishLaunchingreturns.
Test iOS scheduling by pausing in the debugger and running:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.refresh"]
On Android, force a run with adb shell cmd jobscheduler run -f com.example.app <jobId>, and inspect state with adb shell dumpsys jobscheduler | grep -A 20 com.example.app.
Category 3: work the user is watching
If the work must run now and keep running — recording a run, uploading a 500 MB video, playing audio — deferrable scheduling is the wrong tool. You need a foreground service on Android with a persistent notification, and an appropriate background mode on iOS.
Since Android 14, foreground services must declare a type (dataSync, location, mediaPlayback, …) and the runtime rules tightened again in Android 15: dataSync services are capped at roughly six hours per 24-hour period, after which the system stops them. Plan for interruption:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
flutter_background_service gives you a Dart entry point that lives inside that service; long transfers should still checkpoint progress to disk so a kill mid-way resumes rather than restarts. On iOS there is no equivalent — use URLSession background transfers (via background_downloader, which wraps both platforms) and let the OS own the transfer, notifying your app when it completes.
Category 4: silent push that wakes a background isolate
Covered in depth in our push notifications tutorial, but the background-execution rules are the ones that bite: FirebaseMessaging.onBackgroundMessage requires a top-level function annotated @pragma('vm:entry-point'), runs in its own isolate, and gets only a few seconds of wall clock. Do the minimum — write to the database, enqueue a WorkManager task, return. Do not try to run a full sync inside the handler.
On iOS, silent pushes need content-available: 1, are rate-limited by the system, and are dropped entirely in Low Power Mode. Treat them as a hint, never as a delivery guarantee.
Making background work observable
Background work you can't see is background work you can't bill for fixing. On every engagement we add three cheap things:
- A durable run log. The first statement of each task writes a row: task name, trigger, start time. The last writes outcome and duration. One
SELECTthen answers "did it run overnight?" without a debugger. - Breadcrumbs to your crash reporter, initialised inside the background isolate — crash reporting set up in
main()does not exist there. - A hidden diagnostics screen listing scheduled tasks, last run, last result. Support teams use it; so do you, on someone else's device.
Testing it
You can unit test the parts that matter if you keep the OS out of them. Extract the work into a plain class with injected dependencies, and let the platform callback be a three-line adapter:
test('drain stops at the time budget and reports retry', () async {
final clock = FakeClock();
final sync = OutboxSync(db, clock: clock, api: FlakyApi(failAfter: 3));
final ok = await sync.drain(budget: const Duration(seconds: 20));
expect(ok, isFalse); // asks the OS for a retry
expect(await db.outbox.count(), greaterThan(0)); // nothing lost
});
Then add one integration test per platform that actually triggers the scheduler with the commands above. Two tests, run before each release, catch the vm:entry-point regression that would otherwise ship.
The short version
- Classify the work before choosing a tool; most bugs are category errors.
Isolate.runfor compute, a long-lived worker isolate for streams of it.- WorkManager/BGTaskScheduler for "eventually", never for "at 9am sharp".
- Foreground services or platform background transfers for work the user is watching — and budget for Android 15's six-hour cap.
- Annotate every background entry point with
@pragma('vm:entry-point'), re-initialise everything inside the isolate, and log outcomes durably.
Promise behaviour, not schedules. "Your photos upload before the end of the shift, and nothing is lost if the phone dies" is a promise both platforms let you keep. "Every fifteen minutes" is not.
Fighting background sync that works on your device and nowhere else? Get in touch — our Flutter consultants have shipped offline upload queues, location tracking, and background media pipelines across Android and iOS, and can audit yours before it becomes a support queue.