Safe Data Sharing Between Isolates: Using Ports and Sendables
The Isolate Delusion: Why Shared Memory is a Myth in Dart
In the five Nigerian product teams where I’ve introduced my Riverpod architecture guide, the most frequent point of failure isn't the provider graph—it’s the assumption that 'concurrency' in Dart behaves like multi-threading in C# or Java. Developers often come to Flutter expecting that they can spin up a background task, share a reference to a complex object, and modify it from both the main thread and the background worker. Let me be blunt: that is a recipe for disaster.
Dart isolates are true to their name. They do not share memory. They are separate heaps, separate event loops, and separate garbage collectors. When you try to pass data between them, you are not passing a pointer; you are engaging in a serialization process. If you treat isolates as threads sharing memory, you aren't just writing buggy code—you are architecturally bankrupt. Understanding this is the first step toward building performant, responsive multi-screen workflows in Flutter. If your UI freezes during a heavy data transformation, you don't need a faster CPU; you need a better understanding of ports and message-passing.
The Architecture of Communication: Ports and Sendables
Communication between isolates is handled via the SendPort and ReceivePort mechanism. Think of this as a mailbox system. You have a sender who drops a letter into a slot, and a receiver who polls their mailbox for new entries. Because isolates don't share memory, the data is essentially deep-copied (or moved, in newer Dart versions) across the isolate boundary.
This brings us to the concept of 'Sendables'. Not everything can be sent across these boundaries. If you try to send an object that contains a native resource, a closure, or a non-primitive type that doesn't implement specific isolation-friendly interfaces, the engine will throw a SendPort exception. This is why we must adopt a clear architectural boundary between our 'Business Logic' isolates and our 'UI' isolates. My standard for the teams I consult with is simple: use 'Data Transfer Objects' (DTOs) that are specifically designed to be serialized. If your domain models are too heavy, map them to primitive-based representations before pushing them to the port.
1. Defining the Message Contract
Before you even touch an isolate, you need to define your communication contract. Never pass raw maps. Use sealed classes or simple immutable data classes that can be converted to JSON or binary formats.
Step 1: Define your contract structure
// A clear, immutable data contract for our background task
sealed class IsolateTask {
final String requestId;
IsolateTask(this.requestId);
}
class DataProcessingTask extends IsolateTask {
final List<double> rawPayload;
DataProcessingTask(super.requestId, this.rawPayload);
}
class TaskResult {
final String requestId;
final List<double> processedResult;
TaskResult(this.requestId, this.processedResult);
}
By using sealed classes, we ensure that the receiver of the port can exhaustively switch over the types of messages received. This pattern mimics the 'action' approach we use in Redux or Bloc, but it's applied to the inter-isolate communication layer.
2. Orchestrating the Isolate Spawn
Spawning an isolate is not free. It involves setting up a new heap, initializing the VM state, and loading code. If you spawn an isolate inside a build() method, you are doing it wrong. In my architecture, I instantiate background workers either at the entry point of the app or within a Riverpod Provider lifecycle method using ref.onDispose to ensure they are cleaned up correctly.
Step 2: Implementation logic for the background worker
import 'dart:isolate';
Future<void> backgroundWorker(SendPort sendPort) async {
final receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
await for (final message in receivePort) {
if (message is DataProcessingTask) {
// Perform intensive calculation
final result = message.rawPayload.map((e) => e * 2).toList();
sendPort.send(TaskResult(message.requestId, result));
}
}
}
Notice the pattern: the background worker first sends its SendPort to the main isolate. This creates a two-way channel. Without this handshake, the main isolate has no way to send messages back to the worker.
3. Integrating with Riverpod State Management
Once the ports are established, how do we bridge this into our UI? The answer is to wrap the communication logic in a Notifier or a NotifierProvider. Do not expose the SendPort directly to your UI layer. Instead, use a provider that masks the complexity of the isolate communication behind a simple asynchronous API.
Step 3: Wrap the Isolate in a Notifier
class ProcessorNotifier extends Notifier<AsyncValue<List<double>>> {
SendPort? _workerPort;
@override
AsyncValue<List<double>> build() {
_initWorker();
return const AsyncValue.loading();
}
Future<void> _initWorker() async {
final receivePort = ReceivePort();
await Isolate.spawn(backgroundWorker, receivePort.sendPort);
_workerPort = await receivePort.first as SendPort;
}
Future<void> process(List<double> data) async {
final completer = Completer<List<double>>();
// Implementation logic to handle requestId and completers
// ...
_workerPort?.send(DataProcessingTask('id', data));
}
}
This approach ensures that your UI remains completely agnostic of the fact that data processing is happening on another core. If you decide to move this to a web worker later, or replace it with a platform-specific channel, you only change the provider—your UI widgets remain untouched.
Pro-Tips for Production Success
- The Cleanup Clause: Always, always use
ref.onDisposeto kill your isolates. An orphaned isolate is a memory leak that will eventually crash the OS process. If you are using Riverpod 2.0+, use thekeepAlive: falsesetting for providers that manage isolates. - Type-Safety Enforcement: Never pass
dynamicacross a port. If you cannot represent your data in a serializable type, you are using the wrong tool. If you find yourself needing to pass complex object graphs, re-evaluate your domain models to be 'Flat-first' architecture. - Isolate Pooling: Don't spawn one isolate per task. Spawning is slow. Instead, maintain a 'Pool' of workers and distribute tasks using a Round-Robin algorithm. This is what we did for a heavy image-processing app in Lagos last year, and it cut our UI stutter by 70%.
- Handle Exceptions: Isolates crash silently if you don't listen to their error ports. Always use
isolate.addErrorListenerto catch and log exceptions occurring in the background worker.
Decision Framework: When to Use Isolates
When I mentor developers, they often ask: 'Chukwuemeka, should I move this to an isolate?' My answer is always a decision matrix based on complexity and duration.
- Duration < 16ms: If the work takes less than one frame of animation (16ms), keep it on the main thread. Spawning or sending messages to an isolate takes time; if the task is tiny, the overhead of the port communication will be slower than the calculation itself.
- Duration > 16ms, < 100ms: This is the 'Danger Zone'. If it's occasional, use
compute().computeis a high-level abstraction that handles the spawn-and-cleanup boilerplate for you. Use it for one-off JSON parsing or image resizing. - Duration > 100ms or Continuous: This is where you need a dedicated, long-running isolate. Use the
SendPort/ReceivePortpattern described above. This is essential for heavy WebSocket processing, local database synchronization, or complex cryptographic operations.
Do not make 'isolates' your default state of operations. They are a scalpel, not a sledgehammer. Most Flutter performance issues are caused by poor widget rebuilds, inefficient provider graph updates, or unnecessary object allocation in the build() method—not by CPU-bound tasks. Fix your widget lifecycle and your Riverpod select logic first. Only when the main event loop is still saturated should you move logic to the background. By the time you need an isolate, your code should be modular enough that the transition is seamless. If it’s not, you have larger architectural debts to settle before you even think about concurrency.