Isolate & Concurrency #

In the world of modern app development, the ability to leverage the multi-core processing power of today’s hardware is crucial for keeping the user interface smooth. If you let heavy computation block the main thread, users will immediately experience UI jank or even App Freeze. Dart answers this challenge with a very unique architectural approach called Isolates. Unlike traditional multi-threading systems that share memory space, Isolates completely separate memory, eliminating race conditions at the architectural level. We’ll break down Dart’s concurrency model, distinguish Concurrency from Parallelism, and master one-way and two-way Isolate communication techniques for production scenarios.

Concurrency vs Parallelism #

Often, the terms concurrency and parallelism are mistaken for the same thing. In reality, they have fundamental differences in how the processor handles instructions:

  • Concurrency: The ability to handle several tasks alternately on one main thread. Dart implements this using the Event Loop mechanism along with async/await and Future instructions. Here, non-blocking tasks (like waiting for server responses) are released to the operating system, and when data returns, the task is scheduled back into the queue to execute alternately.
  • Parallelism: The ability to execute several tasks at exactly the same time by leveraging multiple physical CPU cores (multi-core CPU). In Dart, this true parallelism can only be achieved by creating new Isolates.

Let’s consider when to use regular async instruments and when to involve Isolates:

  • Use async/await for I/O-bound tasks (tasks that spend a lot of time waiting for external hardware like network requests, reading local databases, or writing files to storage).
  • Use Isolates for CPU-bound tasks (tasks that intensively consume processor computing power, like processing image files, decrypting large data, or running complex mathematical algorithms).

Isolate Architecture: Why No Memory Sharing? #

In traditional operating systems, Threads within the same process share the same heap memory space (Shared-Memory Threading). This approach has a serious weakness: if two threads try to change the same variable simultaneously, data corruption occurs (race condition). To handle this, developers must write complicated memory locks (mutex / locks), which are prone to triggering app deadlocks and burdening processor performance.

Dart solves this problem by designing Isolates. True to their name, each Isolate is a completely self-contained execution container.

  • Each Isolate has its own heap memory and runs its own Event Loop.
  • Isolate A is physically not allowed to access Isolate B’s memory, and vice versa.
  • Because memory isn’t shared (Shared-Nothing Architecture), Dart avoids the need for any mutex or memory locks. This makes code execution 100% guaranteed safe from race condition issues.

Besides safety, another advantage of this architecture is that the Garbage Collector (GC) can run independently in each Isolate without needing to temporarily stop other Isolates. This minimizes visual rendering pauses on the Flutter app screen.

This architecture comparison can be visualized through the following diagram:

flowchart TD
    subgraph Tradisional["Traditional Thread Model (Shared Memory)"]
        direction TB
        T1["Thread 1"] --> SharedMem["Shared Heap Memory"]
        T2["Thread 2"] --> SharedMem
        SharedMem -.->|"Prone to Race Conditions & Deadlocks"| Err["Needs Complex Mutex / Locks"]
    end
    subgraph DartIsolate["Dart Isolate Model (Shared Nothing)"]
        direction TB
        I1["Main Isolate (Heap A)"] -->|"Message Passing"| Port["Port Channel"]
        I2["Worker Isolate (Heap B)"] -->|"Message Passing"| Port
        Port -.->|"Safe & Isolated"| Ok["Race Condition Free"]
    end

The only way for two Isolates to exchange information is by sending data copies through special communication port channels (Message Passing).


The Easiest Way: Isolate.run() and compute() #

Since Dart 3.0, you’ve been given a very practical way to move heavy tasks to a background Isolate without writing manual port boilerplate using the Isolate.run() function.

The Isolate.run() function automatically does three things under the hood:

  1. Dynamically creates (spawns) a new worker Isolate.
  2. Runs the computation function you send on that worker Isolate.
  3. Returns the calculation result to the main Isolate and immediately kills the worker Isolate to save memory.

Isolate.run() Implementation Example #

Let’s compare large JSON parsing execution that could trigger jank with the correct async Isolate solution:

// ANTI-PATTERN: Running large JSON processing on the UI Thread (Triggers Jank!)
Future<List<User>> loadAndParseUsersSync() async {
  final jsonString = await http.read(Uri.parse('https://api.example.com/large-users'));
  
  // This jsonDecode and object mapping runs on the UI Thread.
  // If there are 10,000 items, this operation could take 200ms, freezing the screen instantly!
  final List<dynamic> jsonList = jsonDecode(jsonString);
  return jsonList.map((json) => User.fromJson(json)).toList();
}

// ====================================================================

// CORRECT: Moving the large JSON decoding process to a worker Isolate
Future<List<User>> loadAndParseUsersAsync() async {
  final jsonString = await http.read(Uri.parse('https://api.example.com/large-users'));

  // Running the heavy function on a background Isolate. The UI Thread stays smooth at 120 FPS!
  return await Isolate.run(() {
    final List<dynamic> jsonList = jsonDecode(jsonString);
    return jsonList.map((json) => User.fromJson(json)).toList();
  });
}

Isolate.run() vs compute() #

In the Flutter ecosystem, you may also often see a function called compute().

  • compute() is a thin wrapper function belonging to the Flutter framework, sitting on top of Isolate.run().
  • Main Difference: The compute() function only accepts static or top-level external functions and requires sending parameters separately. Meanwhile, Isolate.run() is much more flexible because it can accept anonymous functions (closures), so you can access local variables directly inside its code block.

Port Communication: SendPort and ReceivePort #

For scenarios where you need more complex communication (like monitoring the progress percentage of a running task), you must manage port channels manually using ReceivePort and SendPort.

  • ReceivePort: Acts as the message receiving door in your Isolate. This port produces a Stream object listening for incoming data.
  • SendPort: Acts as the message sending door. You send messages to another Isolate’s SendPort address so the data reaches its paired ReceivePort.

One-Way Communication Flow #

Here’s a sequence diagram of the basic one-way communication cycle between the Main Isolate and the Worker Isolate:

sequenceDiagram
    participant Main as Main Isolate
    participant Worker as Worker Isolate
    Main->>Main: Create ReceivePort (mainPort)
    Main->>Worker: Isolate.spawn(entryPoint, mainPort.sendPort)
    Note over Worker: Run Heavy Computation
    Worker->>Main: mainPort.sendPort.send(result)
    Main->>Main: Receive Result & Close Port
    Note over Worker: Worker Isolate Stops / Dies

Let’s translate the diagram above into concrete Dart code:

import 'dart:isolate';

// Worker Isolate entry point function (must be a top-level or static function)
void workerEntryPoint(SendPort mainSendPort) {
  // Performing heavy mathematical computation
  int sum = 0;
  for (int i = 1; i <= 5000000; i++) {
    sum += i;
  }

  // Sending the result back to the Main Isolate
  mainSendPort.send(sum);
}

void startOneWayCommunication() async {
  // 1. Creating a receiving door in the Main Isolate
  final mainReceivePort = ReceivePort();

  // 2. Creating the Worker Isolate and giving it the main sending address (SendPort)
  await Isolate.spawn(workerEntryPoint, mainReceivePort.sendPort);

  // 3. Listening for the result message sent by the worker
  mainReceivePort.listen((message) {
    print('Computation result received: $message');
    
    // 4. Always close the ReceivePort when done to avoid memory leaks
    mainReceivePort.close();
  });
}

Long-Lived Worker: Persistent Two-Way Communication #

Creating (spawning) a new Isolate has a startup overhead cost of about 5-10 milliseconds to allocate new heap memory. If you keep creating and killing Isolates for small, frequent tasks, it can actually decrease app efficiency.

The best solution for this problem is creating a Long-Lived Worker Isolate — a worker Isolate created once when the app first runs, staying alive in the background to listen for new work commands, and sending responses multiple times.

To achieve this, you need two-way communication where the Main Isolate and Worker Isolate each hold each other’s SendPort.

Long-Lived JsonWorker Implementation Example #

Let’s design a persistent worker class for repeatedly processing JSON parsing:

import 'dart:async';
import 'dart:convert';
import 'dart:isolate';

class JsonWorker {
  Isolate? _isolate;
  SendPort? _workerSendPort;
  final _readyCompleter = Completer<void>();

  // Starting the worker Isolate initialization
  Future<void> start() async {
    final mainReceivePort = ReceivePort();

    // Listening for the worker's first response (containing its SendPort)
    mainReceivePort.listen((message) {
      if (message is SendPort) {
        _workerSendPort = message;
        _readyCompleter.complete(); // Worker is ready to accept tasks
      } else {
        _handleWorkerResponse(message);
      }
    });

    // Spawning the worker isolate
    _isolate = await Isolate.spawn(_workerEntryPoint, mainReceivePort.sendPort);
    await _readyCompleter.future;
  }

  // Sending a new task to the worker
  void parseJson(String jsonStr) {
    if (_workerSendPort == null) throw StateError('Worker is not ready.');
    _workerSendPort!.send(jsonStr);
  }

  void _handleWorkerResponse(dynamic response) {
    print('Response from Worker: $response');
  }

  // Closing the worker when no longer needed
  void dispose() {
    _isolate?.kill(priority: Isolate.beforeNextEvent);
    _isolate = null;
  }

  // Internal entry point of the continuously running Worker Isolate
  static void _workerEntryPoint(SendPort mainSendPort) {
    final workerReceivePort = ReceivePort();

    // Initial Step: Send the worker's SendPort back to the Main Isolate
    mainSendPort.send(workerReceivePort.sendPort);

    // Listening for repeated work instructions from the Main Isolate
    workerReceivePort.listen((message) {
      if (message is String) {
        // Performing the parsing process in the background
        try {
          final decoded = jsonDecode(message);
          mainSendPort.send({'status': 'success', 'data': decoded});
        } catch (e) {
          mainSendPort.send({'status': 'error', 'message': e.toString()});
        }
      }
    });
  }
}

TransferableTypedData — Transfer Without Copying (Zero-Copy) #

By default, when you send a message containing objects (like List or Map) between Isolates, Dart performs a deep data duplication process (deep copy). This copying process aims to guarantee heap memory isolation is maintained.

However, if you send very large raw binary data (e.g., a 20MB high-resolution image file, audio recordings, or PDF files), the copying process will take a long computation time and waste RAM memory capacity.

To efficiently handle these large byte manipulation cases, Dart provides the TransferableTypedData class. This feature moves binary data ownership rights between Isolate heaps instantly without a memory copying process (Zero-Copy Transfer).

import 'dart:isolate';
import 'dart:typed_data';

void workerProcessBytes(SendPort mainSendPort) {
  // Creating a binary data buffer of 20 Megabytes
  final Uint8List rawBytes = Uint8List(20 * 1024 * 1024);
  
  // Filling the data...
  rawBytes[0] = 255;

  // Wrapping the buffer into TransferableTypedData
  final transferable = TransferableTypedData.fromList([rawBytes]);

  // Sending the data to the main Isolate with ownership transfer (Zero-copy)
  mainSendPort.send(transferable);

  // IMPORTANT: After the transferable is sent, the rawBytes variable inside this Isolate
  // is now invalidated and must not be accessed anymore because its memory has been moved!
}

Isolate Limitations: What Can and Cannot Be Sent? #

Because of strict memory isolation, not all Dart objects can be passed through the SendPort.send() parameter.

Here’s a reference matrix for determining which objects are safe to send:

Object TypeCompatibility StatusDescription
Primitive data types (null, bool, int, double, String)CANSent directly.
Basic collections (List, Map, Set)CANProvided all elements inside are also compatible data types.
SendPortCANVery important for initiating two-way communication paths.
Your own custom class objectsCANRegular Dart objects without native dependencies.
TransferableTypedDataCANSent without copying (zero-copy).
Anonymous Functions (Closures / Lambdas)CANNOTBecause closures carry a memory scope (context) reference of where they were created.
ReceivePortCANNOTReceiving ports cannot move between Isolates.
Native Objects (Socket, open file handles)CANNOTHave direct operating system dependencies that cannot be isolated.

Summary #

  • Isolate Architecture: Dart uses Isolate containers that don’t share heap memory (Shared-Nothing), eliminating race condition risks and the need for memory locks.
  • Concurrency vs Parallelism: Use regular async/await for network-waiting tasks (I/O-bound) and use Isolates for CPU-intensive calculation tasks (CPU-bound).
  • Isolate.run(): The modern practical way to automatically move heavy computation to the background without writing manual communication ports.
  • SendPort & ReceivePort: The basic building blocks of inter-Isolate communication. Always close ReceivePort when done to prevent memory leaks.
  • Long-Lived Worker: The best design pattern for keeping one background Isolate alive to serve repeated tasks without new Isolate creation overhead.
  • TransferableTypedData: Optimizes large binary byte data transfer between Isolates instantly without a memory duplication process (zero-copy).

← Previous: Functional Programming   Next: Best Practice →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact