Skip to content
Tech Stack2 October 2026 · 9 min read

Flutter Background Work: Isolates vs WorkManager

Async, isolates and WorkManager solve three different problems. Where each one belongs, what a background isolate can never receive from the platform, and when the job belongs on a server.

Flutter Background Work: Isolates vs WorkManager

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?

An isometric transport corridor with a large block wedged across the lane and small carriers queued motionless behind it, while a single carrier travels freely around a side loop that rejoins the corridor further along.

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. compute runs 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 rootBundle or 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?

An isometric view of two structures joined by two conduits: the upper one lit, carrying a capsule out and another capsule back, and the lower one sealed by a rust-streaked bolted plate with a capsule stopped against it.

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:

  1. On the root isolate, read RootIsolateToken.instance.
  2. Pass that token into the spawned isolate as part of its single argument.
  3. 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.

JobMechanismWhy
Decode a large JSON or CSV payloadIsolate.run / computeCPU-bound Dart that exceeds a frame; one argument in, one result out
Resize, compress or transcode mediaLong-lived isolate or nativeRepeated spawns cost more than keeping one worker alive
Upload a queued draft after connectivity returnsWorkManager with a network constraintNeeds to survive app death; lateness is acceptable
Refresh a cache or badge count periodicallyWorkManager periodic, 15 min or longerBelow 15 minutes is not schedulable on Android anyway
Listen for live document changesRoot isolate onlyUnsolicited platform messages never reach a background isolate
Charge a card, expire a trial, close a bookingServer jobCorrectness cannot depend on a device waking up
Anything on Flutter webMain thread, or a serverDart 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?

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

What is a background isolate in Flutter?

A background isolate is a separate Dart execution context with its own memory and event loop, used to run work that would otherwise block the frame your UI isolate is building. It does not share variables with the main isolate, so everything it needs arrives as a message, and messages are generally copied rather than shared. Since Flutter 3.7 it can also call plugins, provided you pass a RootIsolateToken across and call BackgroundIsolateBinaryMessenger.ensureInitialized before the first plugin call (Introducing background isolate channels). It cannot touch rootBundle, build widgets, or receive messages the platform sends on its own initiative.

What is the difference between compute and Isolate.run in Flutter?

On mobile and desktop there is no behavioural difference between them, because Flutter's documentation defines one in terms of the other. await compute(fun, message) is equivalent to await Isolate.run(() => fun(message)) (Concurrency and isolates). compute is the older Flutter-specific helper and Isolate.run is the Dart-level API, so new code can reasonably prefer the latter. The real difference is on the web, where Dart platforms do not support isolates at all and compute runs your computation on the main thread. That means a jank fix verified on Android is not automatically a jank fix in the browser build, and nothing warns you about it.

How often can a Flutter WorkManager periodic task run?

On Android, no more often than every 15 minutes. That is the minimum repeat interval for a PeriodicWorkRequest, the same limit the JobScheduler API imposes (Define your work requests), and requesting a shorter interval simply gets clamped. The interval is also a lower bound rather than a schedule: Doze, standby buckets and vendor battery optimisation all decide when the task actually runs. On iOS the plugin sits on BGTaskScheduler, where the system owns the timing outright. Treat any periodic task as eventually-consistent refresh work, never as a clock.

Can I use a Firestore listener in a Flutter background isolate?

No, because a background isolate cannot receive unsolicited messages from the host platform, and a Firestore listener is exactly that. Flutter's documentation calls out this case directly: 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 (Concurrency and isolates). A one-shot query from the background is fine, because your code asks and the platform answers. If you need a live stream of changes, keep the listener on the root isolate and have the background work read from storage instead.

Does Flutter WorkManager keep running after the app is closed?

Scheduled work is meant to survive app restarts and device reboots, but execution is never guaranteed on either platform. Android hands your request to the system scheduler, which batches it against power-saving policy, and many handset vendors layer additional battery optimisation on top that kills background work once the app is swiped away. Behaviour varies by manufacturer and firmware, which is why the symptom usually shows up on one test device and not another. Build anything that must be correct as a server job, and let the device handle only work that is allowed to be late.