An isolate is not a background service, and a background service is not a reliable server.
Three mechanisms get collapsed into one idea in most Flutter codebases: async/await, isolates, and OS-scheduled background tasks. They solve different problems, fail differently, and only one of them survives the app being swiped away. This post separates them and ends with a table for deciding where a given job belongs. If the vocabulary is new, what Flutter is covers the ground underneath it.
Does async already move my code off the UI thread?

No: async/await makes code non-blocking rather than concurrent, so a long synchronous computation inside an async function still drops frames exactly as it would without it.
Dart runs your app on one isolate with one event loop. await hands control back to that loop while something else does the waiting. When the waiting is done by the operating system, your frames keep rendering. When the "waiting" is your own CPU-bound loop over a decoded payload, nothing yields, and the loop owns the thread until it finishes.
That distinction is where most unnecessary isolates come from. A plugin call crossing a platform channel is already off the Dart thread. An HTTP response is already off the Dart thread. Decoding the 4 MB JSON body you got back is not, and that is the part worth measuring.
When does an isolate actually earn its cost?
When your own Dart code runs longer than one frame, which is the only situation Flutter's documentation treats as a hard rule. That rule says to reach for isolates "when large computations are causing your Flutter application to experience UI jank", which it defines as any computation taking longer than Flutter's frame gap (Concurrency and isolates).
Isolate.run is the short form. It "spawns an isolate, passes a callback to the spawned isolate to start some computation, returns a value from the computation, and then shuts the isolate down when the computation is complete". compute is the older Flutter wrapper around the same idea, and on mobile and desktop await compute(fun, message) is equivalent to await Isolate.run(() => fun(message)).
Now the part that gets skipped. Spawning is not free, and neither is moving data across the boundary, because messages are generally copied from the sending isolate to the receiving one. Immutable objects are the exception: a String is passed by reference rather than copied, which is why handing raw response text to an isolate and decoding it there beats decoding on the main thread and shipping the object graph across. Push a 200-microsecond parse into an isolate and you can make the operation measurably slower while the app feels exactly the same.
Two constraints bite later than you would like:
- Flutter web has no isolates at all.
computeruns the computation on the main thread on the web while spawning a real thread on mobile. The jank you fixed on Android is still sitting in your browser build, unchanged, and no warning tells you. - A spawned isolate cannot reach
rootBundleor do widget work. Loading a bundled asset to feed the computation has to happen before you cross the boundary.
Actually, that second point understates the problem. The isolate gets a copy of your globals, so a spawned isolate that reads a global configuration object sees the value as it was at spawn time, and mutating it there changes nothing for the main isolate. Shared mutable state is not a thing you have here. Design the callback to take everything it needs as one argument.
If a booking can only stay correct when a phone wakes up, the architecture is already wrong
A billing or scheduling invariant that depends on a client device running code on time is not a background-work problem. It is a system-design problem wearing a plugin's clothes.
Every mobile OS treats your scheduled task as a request. Android batches it against Doze and whatever the handset vendor layered on top. iOS decides when, and how often, based on usage patterns it does not explain to you. Neither platform promises execution, and both will happily go a day without running your task if the user never opens the app. Build a monthly invoice on top of that and you have built a lottery.
The fix is boring. Make the server the authority for anything that must be true, and let client background work do the things that are allowed to be late: prefetching, warming a cache, uploading a queued draft, refreshing a badge count. When the phone contributes nothing, the system is still correct, just less fresh.
Here is how that line gets drawn on BookBed.
BookBed synchronizes booking data across platforms, but the phone is never the authority that must wake up for the system to remain correct. Client background work can refresh or prepare data; the server owns synchronization and conflict decisions. That separation matters more than choosing between two Flutter plugins.
The same boundary shows up in offline-first Flutter field tools, where the queue on the device is a buffer and the server still resolves the conflicts.
Can a background isolate call my plugins?

Yes, since Flutter 3.7, provided you hand the background isolate a token from the root isolate and initialise the background binary messenger before the first plugin call. Aaron Clarke's January 2023 announcement put it plainly: developers can use plugins and platform channels from any isolate (Introducing background isolate channels). Three steps, in this order:
- On the root isolate, read
RootIsolateToken.instance. - Pass that token into the spawned isolate as part of its single argument.
- Inside the isolate, call
BackgroundIsolateBinaryMessenger.ensureInitialized(token)before touching any plugin.
final token = RootIsolateToken.instance!;
await Isolate.run(() async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
final prefs = await SharedPreferences.getInstance();
// plugin calls are legal from here on
});
Then the limitation that decides your architecture: a background isolate cannot receive unsolicited messages from the host platform. Flutter's own documentation uses the case you were about to write, noting that you cannot set up a long-lived Firestore listener in a background isolate because Firestore pushes updates through platform channels and those pushes are unsolicited, while a one-shot Firestore query in the background is fine (Concurrency and isolates).
The same boundary shapes the backend side of a Flutter app, which Flutter and Firebase for mobile SaaS covers from the data-model end.
Read that as a direction rule. A background isolate can ask the platform a question and wait for the answer. It cannot be told things. Any design that depends on native code calling into your background Dart (a stream of location fixes, or a BLE characteristic notification) has to route through the root isolate or through native storage instead.
What does WorkManager actually guarantee?
Persistence rather than punctuality: a scheduled task survives app restarts and device reboots and runs eventually under the constraints you declared, which is a weaker promise than running at a chosen time.
The floor is concrete on Android. The minimum repeat interval for a PeriodicWorkRequest is 15 minutes, the same limit the JobScheduler API imposes (Define your work requests). Ask for five, get fifteen. On iOS the plugin sits on BGTaskScheduler, where the system owns the schedule outright and an earliestBeginDate is a floor rather than an appointment.
The entry point has one rule worth memorising, because getting it wrong produces a task that silently never runs in release builds: the callback dispatcher must be a top-level function annotated with @pragma('vm:entry-point'), so tree shaking does not remove a function nothing in Dart appears to call (workmanager).
The plugin itself moved recently. The 0.10.x line split into federated packages (workmanager_android, workmanager_apple, workmanager_web, workmanager_linux), renamed the constraint enums to camelCase, and raised the minimum Flutter version. Version 0.10.10, published in September 2026, fixed a failure mode worth knowing about if you target the newest Android: background tasks were dying roughly 30 seconds after start on Android 16 when the Dart isolate booted slowly (workmanager changelog). If your background work has been "flaky on one test device", check your plugin version before you rewrite anything.
And the quiet one: handset vendors ship aggressive battery optimisation that kills scheduled work after the app is swiped away, with behaviour that varies by manufacturer and firmware. Test on the cheap phone, not the Pixel.
Where should this work run?
Match the job to the failure you are trying to avoid, not to how heavy the work sounds, and most of the argument disappears.
| Job | Mechanism | Why |
|---|---|---|
| Decode a large JSON or CSV payload | Isolate.run / compute | CPU-bound Dart that exceeds a frame; one argument in, one result out |
| Resize, compress or transcode media | Long-lived isolate or native | Repeated spawns cost more than keeping one worker alive |
| Upload a queued draft after connectivity returns | WorkManager with a network constraint | Needs to survive app death; lateness is acceptable |
| Refresh a cache or badge count periodically | WorkManager periodic, 15 min or longer | Below 15 minutes is not schedulable on Android anyway |
| Listen for live document changes | Root isolate only | Unsolicited platform messages never reach a background isolate |
| Charge a card, expire a trial, close a booking | Server job | Correctness cannot depend on a device waking up |
| Anything on Flutter web | Main thread, or a server | Dart web platforms do not support isolates |
The first column is the one to argue about with yourself. Most "we need background processing" tickets turn out to be row one or row six, and those two rows do not share a solution.
Getting the answer back into the UI
A WorkManager task runs in its own isolate with no access to your app's memory, so finishing the work and updating a widget are separate problems. The practical pattern is to write the result to shared storage and, when the app happens to be alive, signal it over a port registered with IsolateNameServer (flutter_workmanager issue #541).
Treat the port as an optimisation. Storage is the contract, because the app is usually not running when the task finishes.
Start with one honest question
Open the ticket that made you search for this and ask which of three things it needs: a frame that stops dropping, a job that survives the app closing, or a guarantee that something happens on time. The first is an isolate. The second is WorkManager. The third never belonged on the phone.
Which one is yours?
