skip to content

In Flutter, why does a plugin call fail inside a background isolate by default, and how do RootIsolateToken and BackgroundIsolateBinaryMessenger fix it?

level: seniorimportance: nice to knowfreq 24%

answer

  1. channels route through the root isolate
  2. RootIsolateToken.instance is null elsewhere
  3. pass the token in the message
  4. ensureInitialized before any channel call
  5. requests only, no host-initiated messages

basics

~10 s

Platform channels need a messenger tied to the root isolate, which a spawned isolate lacks. Pass RootIsolateToken.instance from the root isolate and call BackgroundIsolateBinaryMessenger.ensureInitialized(token) in the background isolate; channels then work for request/response calls.

solid answer

~40 s

Plugins talk to Android or iOS over platform channels, and the default messenger belongs to the **root isolate**, the one Flutter started. In a spawned isolate there is no such messenger: a channel call looks up `BackgroundIsolateBinaryMessenger.instance`, which throws a `StateError` until it is initialized. The fix, available since Flutter 3.7: on the root isolate read `RootIsolateToken.instance!` (it is `null` anywhere else), pass it in the isolate's message, and call `BackgroundIsolateBinaryMessenger.ensureInitialized(token)` first thing in the background isolate. After that, `MethodChannel` and plugins built on it pick the background messenger automatically. Limits remain: calls are request/response only, since `setMessageHandler` throws, so the host cannot push unsolicited messages; `rootBundle` and UI work stay unavailable; and none of it exists on the web.

code

dart · 20 lines
dart
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

const MethodChannel _crypto = MethodChannel('example.app/crypto');

// Top-level so compute() sends only the record.
Future<Uint8List?> _encryptInBackground(
  (RootIsolateToken, Uint8List) args,
) async {
  final (RootIsolateToken token, Uint8List bytes) = args;
  BackgroundIsolateBinaryMessenger.ensureInitialized(token);
  // The channel now finds the background messenger automatically.
  return _crypto.invokeMethod<Uint8List>('encrypt', bytes);
}

Future<Uint8List?> encrypt(Uint8List bytes) {
  // Only non-null on the root isolate: read it before spawning.
  final RootIsolateToken token = RootIsolateToken.instance!;
  return compute(_encryptInBackground, (token, bytes));
}

go deeper

for a junior

Know that plugins do not work in a background isolate by default and that a token from the main isolate unlocks them.

for a middle

Explain the two steps, read RootIsolateToken.instance on the root isolate and call ensureInitialized in the worker, and why the token is null elsewhere.

for a senior

Know the limits, request/response only, no host-pushed events, no assets or UI, not on web, and design the worker around them.

for a principal

Decide which plugin work is worth moving off the main isolate versus reading on the main isolate and passing plain data in.

## Why plugins break in a background isolate A **plugin** usually has Dart code that sends messages over a **platform channel** (for example a `MethodChannel`) to Kotlin or Swift code on the host. Those messages are carried by a **binary messenger**. On the main isolate, which Flutter calls the **root isolate**, that messenger is set up by the services binding when the app starts. A spawned isolate, including the one `compute` creates, does not run the Flutter bindings. When a channel inside it needs a messenger, Flutter's lookup sees that it is not on the root isolate and asks for `BackgroundIsolateBinaryMessenger.instance`. If nothing initialized it, that getter throws a `StateError` stating that the instance is invalid until `BackgroundIsolateBinaryMessenger.ensureInitialized` is executed. ## The two pieces - **`RootIsolateToken`** (exported from `package:flutter/services.dart`): an opaque token that identifies the root isolate. `RootIsolateToken.instance` returns it on the root isolate and `null` on any other isolate, so it must be read **before** spawning and handed over. `ServicesBinding.rootIsolateToken` returns the same value. - **`BackgroundIsolateBinaryMessenger.ensureInitialized(token)`**: called inside the background isolate, it registers that isolate with the engine and installs a messenger that sends channel messages through the engine and routes each reply back to the background isolate over a port. Calling it more than once is harmless. After initialization, a `MethodChannel` created without an explicit messenger picks up the background messenger automatically, so most plugins work without changes. ## Steps 1. On the root isolate, read `final token = RootIsolateToken.instance!;`. 2. Send the token with the work, for example as part of a record: `compute(task, (token, bytes))`. 3. At the start of the background function, call `BackgroundIsolateBinaryMessenger.ensureInitialized(token)`. 4. Make the plugin or channel calls you need, then return the result. ## What still does not work | Capability | In a background isolate | |---|---| | Dart to host request with a reply | works after initialization | | Host pushing unsolicited messages to Dart | no: `setMessageHandler` throws `UnsupportedError` | | Long-lived listeners fed by the host | no, for the same reason | | `rootBundle` assets, widgets, `dart:ui` UI work | no: coupled to the root isolate | | Web builds | no: the web stand-in throws `UnsupportedError` | The Flutter concurrency docs give an example of the second row: you cannot keep a long-lived database listener in a background isolate if the plugin delivers updates by pushing them from the host, although a one-off query works. ## When it is worth it The feature exists so that plugin-heavy work, such as encrypting data with a host API, reading preferences or querying a local store, can run next to CPU-heavy Dart code without bouncing results back through the main isolate. If the background work only needs one plugin value, reading it on the main isolate and passing it in the message is often simpler.

  • What happens if you call RootIsolateToken.instance inside the compute() callback?
    It returns `null`, because the token exists only on the root isolate. Using `!` on it throws. Read the token on the main isolate before calling `compute` and pass it in the message.
  • Can a background isolate receive a stream of location updates pushed by a plugin?
    No. Background isolates can send requests and receive replies, but they cannot register handlers for messages the host sends on its own; `setMessageHandler` on the background messenger throws `UnsupportedError`. Keep such subscriptions on the main isolate and forward what the worker needs.

saying these in an interview costs you the question

  • RootIsolateToken.instance works on any isolate.
  • Plugins work in compute() callbacks with no setup at all.
  • After ensureInitialized, the host can push events to the background isolate.
  • BackgroundIsolateBinaryMessenger also lets background isolates load rootBundle assets.
  • Each plugin needs its own messenger passed in by hand after initialization.