Multithreading #

Dart is a single-threaded language — one main thread handles all UI code, user events, and business logic. But this doesn’t mean Dart can’t do many things at once. Dart separates two concepts that are often mixed up: concurrency (handling many tasks in turn on one thread, via async/await) and parallelism (actually running code on several CPU cores simultaneously, via Isolate). Understanding when each is needed — and why async/await is enough for most cases — is a core skill in writing responsive, efficient Dart applications.

The Event Loop — the Foundation of Dart Concurrency #

Before discussing Isolate, it’s important to understand why Dart can “feel” multithreaded while being single-threaded. Dart uses an event loop — a mechanism that processes a queue of events one at a time, but in an order that lets many I/O operations run “concurrently”:

flowchart TD
    A["Dart Code\n(single thread)"] --> B["Event Loop"]
    B --> C{"Any events?"}
    C -- Microtask --> D["Process microtask\n(Future.value, scheduleMicrotask)"]
    C -- Event --> E["Process event\n(Timer, I/O callback, UI event)"]
    D --> C
    E --> C
    B --> F["Microtask Queue\n(higher priority)"]
    B --> G["Event Queue\n(Timer, I/O, user input)"]
void main() async {
  print('1 — synchronous');

  Future.microtask(() => print('2 — microtask'));

  Future.value('3').then((v) => print(v));

  Timer(Duration.zero, () => print('4 — event queue'));

  await Future.delayed(Duration(seconds: 1));

  print('5 — after delay');
}

// Output:
// 1 — synchronous
// 2 — microtask
// 3
// 4 — event queue
// 5 — after delay

The takeaway: async/await and Future use the event loop for concurrency — I/O operations (HTTP, files, database) can wait without blocking the thread. However, heavy computation (CPU-bound) still blocks the thread because actual Dart code can only run one statement at a time.


Async/Await Is Usually Enough — When You Don’t Need Isolate #

This is the most commonly mistaken decision: developers use Isolate for problems that could actually be solved with async/await.

// Does NOT need Isolate — I/O bound, can be solved with async/await
Future<List<Produk>> ambilProduk() async {
  final response = await http.get(Uri.parse('/api/produk')); // waiting on the network
  return jsonDecode(response.body)
      .map<Produk>((j) => Produk.fromJson(j))
      .toList();
}

// Does NOT need Isolate — reading files, can be async
Future<String> bacaFile(String path) async {
  return await File(path).readAsString(); // waiting on disk I/O
}

// NEEDS Isolate — CPU-bound, blocks the thread if not isolated
List<int> sortDataBesar(List<int> data) {
  data.sort(); // If data has 10 million elements — blocks the thread for seconds!
  return data;
}

// NEEDS Isolate — heavy parsing/computation
Map<String, dynamic> parseJsonBesar(String jsonString) {
  return jsonDecode(jsonString); // A 50MB JSON can block the thread > 1 second
}
USE async/await (no Isolate needed) when:
  ✓ I/O operations: HTTP, files, database, sockets
  ✓ Waiting operations: Timer, delay, user input
  ✓ Light computation between events

USE Isolate when:
  ✓ Heavy CPU-bound computation (> ~16ms for 60fps UI)
  ✓ Parsing very large files/JSON
  ✓ Image processing, encryption, compression
  ✓ Machine learning inference
  ✓ Operations that can be split and parallelized

Isolate.run — the Modern API (Dart 2.19+) #

The easiest way to run heavy computation on a separate Isolate. Isolate.run creates a new Isolate, runs the function, returns the result, then automatically cleans up the Isolate:

import 'dart:isolate';

// The function to run on a separate Isolate
// MUST be top-level or static — can't be a closure capturing outer variables
int hitungFibonacci(int n) {
  if (n <= 1) return n;
  return hitungFibonacci(n - 1) + hitungFibonacci(n - 2);
}

void main() async {
  print('Starting computation...');

  // Isolate.run — run in the background, await the result
  final hasil = await Isolate.run(() => hitungFibonacci(40));

  print('Fibonacci(40) = $hasil'); // 102334155
  print('UI stays responsive during computation!');
}

Sending Data to Isolate.run #

Data is sent via a closure or function parameters. Dart copies the data to the new Isolate:

Future<List<Produk>> prosesDataBesar(List<Map<String, dynamic>> rawData) async {
  // Send rawData to the Isolate, process it there, return the result
  return await Isolate.run(() {
    // rawData is copied to this Isolate — no memory sharing
    return rawData
        .map(Produk.fromJson)
        .where((p) => p.stok > 0)
        .toList();
  });
}

// With explicit parameters — clearer
Future<String> kompresString(String input) async {
  return await Isolate.run(() {
    // Simulate heavy compression
    return _algoritmKompresi(input);
  });
}
// ANTI-PATTERN: `Isolate.run` for I/O operations — inefficient
// I/O is already non-blocking in Dart, Isolate overhead is unnecessary
final result = await Isolate.run(() async {
  return await http.get(Uri.parse('/api/data')); // ✗ overhead without benefit
});

// CORRECT: just await for I/O
final result = await http.get(Uri.parse('/api/data')); // ✓

Isolate.spawn — Full Control #

For more complex cases — two-way communication, persistent isolates, or sending many messages — use Isolate.spawn with SendPort/ReceivePort:

One-Way Communication (Simple) #

import 'dart:isolate';

// Isolate entry points MUST be top-level functions
void entryPoint(SendPort kirimKe) {
  // Do the work
  final hasil = hitungSesuatuYangBerat();
  // Send the result back to the main isolate
  kirimKe.send(hasil);
}

void main() async {
  // Create a ReceivePort to receive messages from the new Isolate
  final terima = ReceivePort();

  // Spawn the Isolate, send it a SendPort so it can reply
  final isolate = await Isolate.spawn(entryPoint, terima.sendPort);

  // Wait for one message
  final hasil = await terima.first;
  print('Hasil: $hasil');

  // Clean up
  terima.close();
  isolate.kill();
}

Two-Way Communication — the Common Pattern #

For full two-way communication, the new Isolate needs to send its own SendPort back to the main isolate:

import 'dart:isolate';

// Initialization message
class InitPesan {
  final SendPort kirimKe;
  const InitPesan(this.kirimKe);
}

// Worker Isolate — receives jobs and sends results
void workerEntryPoint(SendPort kirimKeMain) {
  // Create a ReceivePort to receive commands from main
  final terimaPerintah = ReceivePort();

  // Send our SendPort to main so main can send commands
  kirimKeMain.send(terimaPerintah.sendPort);

  // Listen for commands from main
  terimaPerintah.listen((pesan) {
    if (pesan is int) {
      // Process the job
      final hasil = hitungFibonacci(pesan);
      kirimKeMain.send(hasil);
    } else if (pesan == 'tutup') {
      terimaPerintah.close();
    }
  });
}

void main() async {
  final terimaMain = ReceivePort();

  // Spawn the worker isolate
  await Isolate.spawn(workerEntryPoint, terimaMain.sendPort);

  // Receive the SendPort from the worker
  final SendPort kirimKeWorker = await terimaMain.first;

  // Send a job to the worker
  kirimKeWorker.send(40);  // compute fibonacci(40)
  final hasil1 = await terimaMain.first;
  print('Fibonacci(40) = $hasil1');

  kirimKeWorker.send(35);  // next job
  final hasil2 = await terimaMain.first;
  print('Fibonacci(35) = $hasil2');

  // Done
  kirimKeWorker.send('tutup');
  terimaMain.close();
}

Transferring Data Between Isolates #

Because Isolates don’t share memory, data must be transferred via messages. Dart uses two strategies:

Copy — for Small Data #

Data is copied from one Isolate to another. Both have independent copies:

// All primitive types and standard collections are copied
isolate.send(42);                    // int — copied
isolate.send('halo');               // String — copied
isolate.send([1, 2, 3]);           // List — copied (deep copy)
isolate.send({'a': 1});            // Map — copied (deep copy)

Transfer — for Large Data (Zero-Copy) #

Some types can be transferred without copying — the object is moved to the receiving Isolate and is no longer accessible from the sending Isolate:

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

// TransferableTypedData — for large binary data (images, audio, etc.)
// Zero-copy transfer — very efficient for large data
Future<void> prosesGambar(Uint8List pixelData) async {
  // Wrap as TransferableTypedData for zero-copy
  final transferable = TransferableTypedData.fromList([pixelData]);

  final terima = ReceivePort();
  await Isolate.spawn(
    _prosesGambarEntryPoint,
    [terima.sendPort, transferable],
  );

  final hasilPixel = await terima.first as Uint8List;
  terima.close();
  return hasilPixel;
}

void _prosesGambarEntryPoint(List<dynamic> args) {
  final SendPort kirimKe = args[0];
  final TransferableTypedData data = args[1];

  // Materialize back into a Uint8List
  final pixels = data.materialize().asUint8List();

  // Process the image...
  final hasilPixel = _filterGrayscale(pixels);

  kirimKe.send(hasilPixel);
}
// ANTI-PATTERN: sending a custom class object that can't be serialized
class Pengguna {
  final String nama;
  final Stream<int> stream; // ✗ Streams can't be sent between Isolates
  Pengguna(this.nama, this.stream);
}

// The Isolate will throw: Illegal argument in isolate message
isolate.send(Pengguna('Budi', stream));

// CORRECT: send serializable data (primitives, Map, List)
isolate.send({'nama': 'Budi', 'umur': 25}); // ✓ Map is copied
isolate.send(pengguna.toJson());             // ✓ convert to a Map first

Isolate Pools — Reuse #

Creating a new Isolate for every task has significant overhead. For frequent tasks, create a pool of isolates ready to accept jobs:

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

class IsolatePool {
  final int ukuran;
  final _workers = <_Worker>[];
  int _indeksBerikutnya = 0;

  IsolatePool({this.ukuran = 4});

  Future<void> inisialisasi() async {
    for (int i = 0; i < ukuran; i++) {
      final worker = _Worker();
      await worker.mulai();
      _workers.add(worker);
    }
  }

  Future<T> jalankan<T>(dynamic Function() tugas) {
    // Round-robin assignment to workers
    final worker = _workers[_indeksBerikutnya];
    _indeksBerikutnya = (_indeksBerikutnya + 1) % ukuran;
    return worker.jalankan<T>(tugas);
  }

  void tutup() {
    for (final w in _workers) w.tutup();
  }
}

class _Worker {
  late Isolate _isolate;
  late SendPort _kirimKe;
  final _tugas = <Completer>{};

  Future<void> mulai() async {
    final terima = ReceivePort();
    _isolate = await Isolate.spawn(_workerLoop, terima.sendPort);
    _kirimKe = await terima.first;
    // Set up a listener for results...
  }

  Future<T> jalankan<T>(dynamic Function() tugas) {
    final completer = Completer<T>();
    _kirimKe.send(tugas);
    // Store the completer to resolve when the result arrives
    return completer.future;
  }

  void tutup() => _isolate.kill();
}

compute in Flutter — Simplified Isolate #

Flutter provides the compute function, which wraps Isolate.run with a simpler API. This is the most idiomatic way in Flutter to move heavy computation to the background:

import 'package:flutter/foundation.dart';

// The function must be top-level or static
List<Produk> _parseJsonDiBackground(String jsonString) {
  final data = jsonDecode(jsonString) as List;
  return data.map((j) => Produk.fromJson(j as Map<String, dynamic>)).toList();
}

// Inside a widget or provider
Future<void> muatProduk() async {
  final jsonString = await ambilJsonDariApi();

  // compute() — run parsing on a separate Isolate
  // The UI stays smooth while parsing happens
  final produk = await compute(_parseJsonDiBackground, jsonString);

  setState(() => _produk = produk);
}
// ANTI-PATTERN: parsing large JSON on the main isolate — blocks the UI
Future<void> muatProduk() async {
  final response = await http.get(Uri.parse('/api/produk'));
  // ✗ jsonDecode of 10MB on the main thread = UI freeze for seconds
  final produk = (jsonDecode(response.body) as List)
      .map<Produk>((j) => Produk.fromJson(j))
      .toList();
  setState(() => _produk = produk);
}

// CORRECT: move it to the background with compute
Future<void> muatProduk() async {
  final response = await http.get(Uri.parse('/api/produk'));
  // ✓ parsing in the background — the UI doesn't freeze
  final produk = await compute(_parseProduk, response.body);
  setState(() => _produk = produk);
}

// Must be a top-level function
List<Produk> _parseProduk(String json) {
  return (jsonDecode(json) as List).map<Produk>(Produk.fromJson).toList();
}

Isolate Limitations to Understand #

// 1. Can't send closures that capture outer variables
int multiplier = 3;
await Isolate.run(() => 10 * multiplier); // ✗ can be problematic

// 2. Can't send objects with native resources (sockets, file handles)
final socket = await Socket.connect('localhost', 8080);
isolate.send(socket); // ✗ impossible — native resources can't be transferred

// 3. Can't send functions (except as top-level references)
isolate.send((x) => x * 2); // ✗ closures can't be sent
isolate.send(hitungFibonacci); // ✓ top-level function references can

// 4. Spawn overhead — don't spawn an Isolate for very short tasks
await Isolate.run(() => 1 + 1); // ✗ spawn overhead is much larger than the computation

// 5. No shared state — data must be sent explicitly
// There's no global variable accessible across Isolates

Comparison: async/await vs Isolate #

flowchart TD
    A{"What kind of task<br/>needs doing?"} --> B{"I/O bound?<br/>HTTP, file, DB"}
    B -- Yes --> C["async/await<br/>Future, Stream"]
    B -- No --> D{"CPU bound?<br/>Uses &gt; ~16ms"}
    D -- No --> E["async/await<br/>is enough"]
    D -- Yes --> F{"Dart 2.19+?"}
    F -- Yes --> G["Isolate.run<br/>or Flutter compute"]
    F -- No --> H["Isolate.spawn<br/>+ SendPort/ReceivePort"]
    G --> I{"Need a persistent<br/>Isolate / two-way<br/>communication?"}
    I -- Yes --> H
    I -- No --> G
Aspectasync/awaitIsolate
Memory modelShared (single thread)Separate (not shared)
OverheadVery lowHigh (spawn + data copy)
Best forI/O, events, UIHeavy CPU computation
CommunicationDirect via variablesMessage passing
Can share objects✗ (only transfer/copy)
Blocks UI?✓ if CPU-bound✗ runs on another core

Summary #

  • Dart is single-threaded, but the event loop lets many I/O operations run “concurrently” via async/await — without blocking the main thread.
  • async/await is enough for all I/O — HTTP, files, database, sockets. Isolate is not the solution for slow I/O problems.
  • Use Isolate for CPU-bound work — computation taking > 16ms: large data parsing, encryption, compression, image processing, ML inference.
  • Isolate.run (Dart 2.19+) is the easiest way to run computation in the background — create an Isolate, run the function, return the result, clean up automatically.
  • compute in Flutter is a more idiomatic wrapper around Isolate.run — use it as the default for moving heavy parsing/computation off the UI thread.
  • Isolates don’t share memory — all data must be sent via messages. Primitive types and standard collections are copied; TransferableTypedData for zero-copy transfer of large data.
  • Not all objects can be sent — closures, sockets, file handles, and objects with native resources can’t be sent between Isolates.
  • Isolate entry points must be top-level or static — can’t use closures capturing variables from the outer scope.
  • Avoid spawning an Isolate for short tasks — spawn overhead can be larger than the computation itself. For frequent tasks, consider an Isolate pool.
  • Isolate.spawn for two-way communication — when you need a persistent Isolate receiving many commands and sending many results.

← Previous: Pub.dev   Next: I/O →

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