Language

Flutter Background Isolates: True Concurrency Without Blocking the UI

Flutter runs on a single main thread — the main isolate — responsible for rendering the UI at 60 or 120 fps and handling user input. Any heavy work you put on that thread shows immediately: dropped frames, stuttered animations, and an app that feels sluggish.

Dart’s answer is the isolate: a fully independent unit of execution with its own isolated memory and its own event loop. Offloading work to a background isolate frees the main thread to do the one thing it must do well — paint the interface.

In this article we’ll explore what background isolates are, how they work internally, when to use them, and how they fit into Bluetooth Low Energy apps.

Let’s get started!


Why Does the Problem Exist?

Dart is single-threaded by design. Unlike Java or Kotlin, there are no shared threads and no mutexes. All execution happens inside an isolate — and by default, your app has exactly one.

The main isolate has a strict per-frame budget: ~16 ms at 60 fps or ~8 ms at 120 fps. Any operation that takes longer than that blocks the renderer.

1
2
3
Main isolate timeline (no background isolates):

[frame 1] [render] [frame 2] [render] [HEAVY WORK............] [frame 3] ← JANK

With a background isolate:

1
2
Main isolate:   [frame 1] [render] [frame 2] [render] [frame 3] ← smooth
Background: [______ HEAVY WORK _________________________________]

The Dart Isolate Model

An isolate in Dart is similar to a lightweight OS process:

  • It has its own memory heap — it shares no objects with other isolates.
  • It communicates exclusively via message passing through SendPort / ReceivePort.
  • It runs on a separate OS thread, enabling true parallelism on multi-core CPUs.
1
2
3
4
5
6
7
8
9
10
11
12
┌──────────────────────────────────────────────────┐
│ Dart VM │
│ │
│ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ Main Isolate │ │ Background Isolate │ │
│ │ │◄──►│ │ │
│ │ - UI │ │ - Heavy compute │ │
│ │ - Gestures │ │ - JSON parsing │ │
│ │ - Animation │ │ - Crypto / ML │ │
│ └─────────────────┘ └─────────────────────┘ │
│ (messages) │
└──────────────────────────────────────────────────┘

Key point: isolates do not share memory. To send data between them, Dart copies it (for primitives and simple collections) or transfers it (for special types like TransferableTypedData). This eliminates race conditions by design.


How to Use a Background Isolate

Option 1 — compute() (simplest approach)

compute is a Flutter helper that spawns a temporary isolate, waits for the result, and shuts it down. It is ideal for one-shot, stateless tasks.

1
2
3
4
5
6
7
8
9
10
import 'package:flutter/foundation.dart';

// This function runs in the background isolate
List<Device> parseDevices(String jsonString) {
final List decoded = jsonDecode(jsonString);
return decoded.map((e) => Device.fromJson(e)).toList();
}

// In your widget or bloc:
final devices = await compute(parseDevices, rawJson);

Important restriction: the top-level function (or static method) passed to compute cannot capture closures from the main isolate’s environment. It must be a pure function.


Option 2 — Isolate.spawn() (full control)

For long-running tasks or bidirectional communication, use Isolate.spawn directly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import 'dart:isolate';

Future<void> startBackgroundIsolate() async {
final receivePort = ReceivePort();

// Spawn the isolate, passing our SendPort so it can reply
await Isolate.spawn(_backgroundEntry, receivePort.sendPort);

// Listen for messages from the isolate
receivePort.listen((message) {
if (message is SendPort) {
// The isolate sends its own SendPort for bidirectional communication
final isolateSendPort = message;
isolateSendPort.send({'command': 'start'});
} else {
print('Result received: $message');
}
});
}

// Entry point of the background isolate — must be top-level or static
void _backgroundEntry(SendPort mainSendPort) {
final isolateReceivePort = ReceivePort();

// Send our SendPort to the main isolate so it can send us commands
mainSendPort.send(isolateReceivePort.sendPort);

isolateReceivePort.listen((message) {
if (message is Map && message['command'] == 'start') {
final result = _doHeavyWork();
mainSendPort.send(result);
}
});
}

String _doHeavyWork() {
// Intensive computation...
return 'work complete';
}

Option 3 — Isolate.run() (Dart 2.19+, the modern way)

Since Dart 2.19, Isolate.run() combines the best of both: the simplicity of compute with closure support.

1
2
3
4
5
6
import 'dart:isolate';

final result = await Isolate.run(() {
// Closures over external scope values work here (values, not references)
return expensiveComputation(data);
});

Prefer Isolate.run() over compute() in new projects — it is more ergonomic and is the modern Dart standard.


Accessing Plugins from Background Isolates (Flutter 3.7+)

Before Flutter 3.7, background isolates could not call native plugins (platform channels). This was a significant limitation for BLE and sensor apps.

Since Flutter 3.7, this is possible via BackgroundIsolateBinaryMessenger:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import 'dart:isolate';
import 'package:flutter/services.dart';

void main() async {
WidgetsFlutterBinding.ensureInitialized();

// Capture the token from the main isolate
final token = RootIsolateToken.instance!;

await Isolate.spawn(_bleIsolateEntry, token);
}

void _bleIsolateEntry(RootIsolateToken token) async {
// Register the messenger before using any plugin
BackgroundIsolateBinaryMessenger.ensureInitialized(token);

// Now you can use platform channels and plugins from here
const channel = MethodChannel('com.example/ble');
final result = await channel.invokeMethod('startScan');
print('Scan started from background isolate: $result');
}

Efficient Data Transfer — TransferableTypedData

Copying large byte buffers between isolates can be expensive. For binary data (like BLE frames), use TransferableTypedData, which transfers the memory without copying it:

1
2
3
4
5
6
7
8
9
10
11
12
// In the main isolate — pack for transfer
final bytes = Uint8List.fromList([0x01, 0x02, 0x03, 0xFF]);
final transferable = TransferableTypedData.fromList([bytes]);
sendPort.send(transferable);

// In the background isolate — unpack
receivePort.listen((message) {
if (message is TransferableTypedData) {
final data = message.materialize().asUint8List();
// Process data with no extra copy
}
});

Use Cases

Use case Why an isolate
Large JSON parsing Would block the render thread if done inline
Compression / decompression CPU-intensive, takes tens of ms
Encryption / hashing AES, SHA256 over large buffers
Image decoding Before passing to a Canvas or Image widget
BLE frame processing Raw bytes → domain structs
Heavy SQLite queries Avoids I/O latency on the main thread
ML model inference TFLite running on the background isolate

Isolates and BLE Apps

This is perhaps the most practical combination. BLE apps receive a continuous stream of data — characteristic notifications, scan results, protocol frames — and need to process all of it without impacting the UI.

The Problem Without Isolates

1
2
3
BLE Plugin → Main Isolate → [frame parsing] → UI update

Jank happens here if parsing is slow!

Solution With a Background Isolate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// Recommended architecture for BLE + Isolate

class BleProcessor {
late final SendPort _isolateSendPort;
final _resultStream = StreamController<DeviceData>.broadcast();

Stream<DeviceData> get dataStream => _resultStream.stream;

Future<void> initialize() async {
final receivePort = ReceivePort();
await Isolate.spawn(_processorIsolate, receivePort.sendPort);

receivePort.listen((message) {
if (message is SendPort) {
_isolateSendPort = message;
} else if (message is DeviceData) {
_resultStream.add(message);
}
});
}

void processRawFrame(Uint8List bytes) {
// Send raw bytes to the isolate for decoding
_isolateSendPort.send(TransferableTypedData.fromList([bytes]));
}
}

void _processorIsolate(SendPort mainPort) {
final port = ReceivePort();
mainPort.send(port.sendPort);

port.listen((message) {
if (message is TransferableTypedData) {
final bytes = message.materialize().asUint8List();
// Decode the BLE device's proprietary protocol
final data = _decodeFrame(bytes);
mainPort.send(data);
}
});
}

DeviceData _decodeFrame(Uint8List bytes) {
// Example: health device protocol
// Byte 0: packet type
// Bytes 1-4: timestamp (little-endian)
// Bytes 5-6: sensor value
final type = bytes[0];
final timestamp = ByteData.sublistView(bytes, 1, 5).getUint32(0, Endian.little);
final value = ByteData.sublistView(bytes, 5, 7).getUint16(0, Endian.big);
return DeviceData(type: type, timestamp: timestamp, value: value);
}

Flutter 3.7+ — The Isolate Calls the BLE Plugin Directly

1
2
3
4
5
6
7
8
9
10
11
12
13
void _bleBackgroundIsolate(RootIsolateToken token) async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);

// The isolate can scan, connect, and read characteristics on its own
FlutterBluePlus.scanResults.listen((results) {
for (final result in results) {
final processed = _processAdvertisement(result.advertisementData);
// Send processed result back to the main isolate
}
});

await FlutterBluePlus.startScan(timeout: const Duration(seconds: 10));
}

Best Practices

1. Use Isolate.run() for one-shot tasks

1
2
3
4
5
6
// ✅ Clean, modern, no boilerplate
final result = await Isolate.run(() => heavyParsing(data));

// ❌ Avoid — unnecessarily verbose for simple tasks
final port = ReceivePort();
await Isolate.spawn(heavyParsing, port.sendPort);

2. Don’t overuse isolates for fast operations

Spawning an isolate has an overhead of ~1–2 ms plus data copying. For operations that take less than ~5 ms, the overhead outweighs the benefit.

1
2
3
4
5
// ❌ Not worth it — too trivial
final sum = await Isolate.run(() => list.fold(0, (a, b) => a + b));

// ✅ Better — just run it inline
final sum = list.fold(0, (a, b) => a + b);

3. Reuse long-lived isolates for BLE data streams

Do not spawn a new isolate for every BLE frame received. Create a dedicated isolate at startup and keep it alive for the entire connection session.

1
2
3
4
5
6
7
8
// ✅ One isolate, many frames
class BleFrameProcessor {
SendPort? _port;

Future<void> start() async { /* spawn once */ }
void processFrame(Uint8List frame) => _port?.send(frame);
void dispose() { /* kill the isolate when the session ends */ }
}

4. Prefer TransferableTypedData for large buffers

1
2
3
4
5
// ❌ Copies the entire buffer
sendPort.send(largeUint8List);

// ✅ Zero-copy transfer — critical for frequent BLE frames
sendPort.send(TransferableTypedData.fromList([largeUint8List]));

5. Call BackgroundIsolateBinaryMessenger.ensureInitialized() first

If your isolate needs to access native plugins, this must be the very first line it executes. Any plugin call before this will throw a MissingPluginException.

1
2
3
4
5
void _myIsolate(RootIsolateToken token) async {
// ✅ Always the first line
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
// ... rest of the code
}

6. Handle isolate errors from the main isolate

Uncaught errors inside an isolate do not propagate to the main isolate automatically. Use onError when spawning to capture them.

1
2
3
4
5
6
7
8
9
10
11
12
final errorPort = ReceivePort();
await Isolate.spawn(
_myIsolateEntry,
sendPort,
onError: errorPort.sendPort,
);

errorPort.listen((error) {
final List errorData = error as List;
print('Isolate error: ${errorData[0]}');
print('Stack trace: ${errorData[1]}');
});

7. Always close ReceivePorts you no longer need

Active ReceivePorts prevent the isolate from being garbage collected. Close them explicitly when done.

1
2
3
final port = ReceivePort();
// ... use the port
port.close(); // ✅ Releases resources

Summary

Background isolates are Dart’s answer to the concurrency problem: true parallelism without race conditions, thanks to isolated memory and message passing.

For BLE apps in Flutter, they are an indispensable tool. The continuous stream of data from a connected device can be parsed, decoded, and filtered on a dedicated isolate while the UI remains completely smooth. With Flutter 3.7+, that isolate can even call native plugins directly, removing the last barrier to robust background BLE architectures in Flutter.

The golden rule is simple: if it blocks the main thread for more than one frame, move it to an isolate.

Have a great weekend!


References

  1. Dart — Isolates documentation
  2. Flutter — Background isolates (flutter.dev)
  3. BackgroundIsolateBinaryMessenger — Flutter 3.7 release notes
  4. Dart 2.19 — Isolate.run()
  5. flutter_blue_plus — pub.dev

Found this helpful? Buy me a coffee ☕

Your support helps me keep writing and sharing 🙏

Comments