+1 (415) 480-3939

Background Work in Flutter That Actually Runs: Isolates, WorkManager, and BGTaskScheduler

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:

KindExampleRuns when app is killed?Tool
Off-main-thread computeParsing a 12 MB JSON payload, image resize, cryptoNo — app must be aliveIsolate.run / compute
Deferrable, guaranteed-ish workUpload queued photos, flush an outbox, refresh a tokenYes, eventuallyWorkManager (Android) / BGTaskScheduler (iOS)
User-visible ongoing workTurn-by-turn navigation, run tracking, audio, long file transferYes, while it's visibleForeground service (Android) / background modes (iOS)
Server-initiated wake-ups"New data available" syncYes, at OS discretionData/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: BGAppRefreshTask runs 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 false to 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 BGTaskSchedulerPermittedIdentifiers and the processing/fetch background modes to Info.plist, and register identifiers before application:didFinishLaunching returns.

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:

  1. 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 SELECT then answers "did it run overnight?" without a debugger.
  2. Breadcrumbs to your crash reporter, initialised inside the background isolate — crash reporting set up in main() does not exist there.
  3. 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.run for 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.