Skip to content
Blog

Async in Dart: Isolates, Futures, and Streams Deep Dive

A code-first deep dive into Dart concurrency: how the event loop drives Futures and Streams, and how isolates unlock real parallelism for CPU-bound work like image processing.

Published on August 12, 2026

AI Assistant

async/await is the surface; underneath lie the event loop, Futures and Streams for work over time, and Isolates for work that must run in parallel. By the end you will explain Dart’s two event-loop queues, write error-safe Future pipelines, pick between single-subscription and broadcast Streams, and offload CPU-bound work to background isolates.

Prerequisites

  • Dart SDK 2.19+ (for Isolate.run) or the latest stable Flutter, plus basic async/await familiarity.

The event loop: single-threaded async under the hood

All Dart code runs inside an isolate: one thread, one event loop, two queues. The microtask queue (.then() callbacks, Future.microtask(), stream handlers) drains completely before the event queue (timers, network responses, user input). await pauses this function only; the loop stays free to render frames and handle taps.

void main() {
  Future(() => print('event 1'));
  Future(() => print('event 2'));
  Future.microtask(() => print('microtask 1'));
  print('synchronous');
}
// Output: synchronous, microtask 1, event 1, event 2

Futures with async/await: values that arrive once

A Future<T> delivers a single value or error later. Handle errors explicitly; an unhandled future error is a silent crash. Independent operations should run concurrently, not sequentially.

Future<Map<String, dynamic>> fetchUser(int id) async {
  final r = await http.get(Uri.parse('/users/$id'));
  if (r.statusCode != 200) throw Exception('Load failed: $id');
  return jsonDecode(r.body) as Map<String, dynamic>;
}
Future<void> safeFetch() async {
  try {
    final user = await fetchUser(42);
    print(user);
  } on FormatException catch (e) {
    print('Bad JSON: ${e.message}');
  } catch (e, stackTrace) {
    log.error('Async failure', error: e, stackTrace: stackTrace);
    rethrow;
  }
}
final (user, prefs) = await (fetchUser(1), fetchPrefs(1)).wait;
final results = await Future.wait([fetchUser(1), fetchPrefs(1)]);

Streams: values that arrive over time

A Stream<T> is an asynchronous sequence of values. Consume it with listen() or the await for loop; generate it with async*.

Stream<int> countDown(int from) async* {
  for (var i = from; i >= 0; i--) {
    yield i;
    await Future.delayed(const Duration(seconds: 1));
  }
}
Future<void> run() async {
  await for (final n in countDown(3)) {
    print(n);
  }
}

StreamController gives manual control. Broadcast streams allow multiple listeners; single-subscription streams allow exactly one. Use broadcast for UI taps or sensor data; single-subscription for file reads and HTTP bodies. Always pass onError to listen() — silent stream errors are a classic leak.

final controller = StreamController<int>.broadcast();
controller.stream.listen((n) => print('A: $n'));
controller.stream.listen((n) => print('B: $n'));
controller.add(1);
controller.add(2);
controller.close();

Isolates: real parallelism without shared memory

Isolates are like threads, except each has its own heap and no shared state. They communicate only by passing messages through SendPort/ReceivePort — the actor model, no data races, no mutexes. The one-shot API is Isolate.run (Dart 2.19+):

int slowFib(int n) => n <= 1 ? 1 : slowFib(n - 1) + slowFib(n - 2);
Future<void> fib40() async {
  final result = await Isolate.run(() => slowFib(40));
  print('Fib(40) = $result');
}

For long-lived workers use Isolate.spawn plus ports: the main isolate creates a ReceivePort, passes its SendPort to the worker, the worker replies with its own SendPort, and both sides message freely.

Future<int> runInWorker(String data) async {
  final port = ReceivePort();
  await Isolate.spawn(_worker, port.sendPort);
  port.send(data);
  return await port.first as int;
}
void _worker(SendPort mainPort) {
  final workerPort = ReceivePort();
  mainPort.send(workerPort.sendPort);
  workerPort.listen((message) {
    mainPort.send((message as String).codeUnits.length);
  });
}

Isolate.spawnUri loads code from a separate URI; it is slower than spawn() and joins no isolate group, so prefer spawn() unless you genuinely need isolated code loading. A spawn result shares its parent’s isolate group, enabling code-sharing optimizations, Isolate.exit, and faster message passing between members than across groups.

compute() in Flutter

Flutter wraps Isolate.run in the compute helper. Use top-level or static functions only — no closures capturing state. The UI isolate stays smooth while the worker encodes; on the web only the main isolate runs, so keep the hot path small.

Future<Uint8List> encodeFrame(Uint8List raw, int quality) {
  return compute(_jpegEncode, (raw, quality));
}
Uint8List _jpegEncode((Uint8List raw, int quality) args) {
  final (raw, quality) = args;
  return img.encodeJpg(img.decodeImage(raw)!, quality: quality);
}

Putting it all together

This runnable program fans CPU-bound thumbnail work across cores with Isolate.run, aggregates via Future.wait, and streams progress through a broadcast StreamController:

import 'dart:async';
import 'dart:isolate';
Future<int> makeThumb(int imageId) {
  return Isolate.run(() {
    var sum = 0;
    for (var i = 0; i < 10_000_000; i++) {
      sum = (sum + imageId * i) % 1_000_003;
    }
    return sum;
  });
}
Future<void> main() async {
  final progress = StreamController<int>.broadcast();
  progress.stream.listen((id) => print('done: image $id'));
  final results = await Future.wait(
    List.generate(8, (i) => i + 1).map((id) async {
      final thumb = await makeThumb(id);
      progress.add(id);
      return (id, thumb);
    }),
  );
  await progress.close();
  print('thumbnails: ${results.length}');
}

Each makeThumb runs on its own isolate, so all 8 jobs execute across cores in parallel while the main isolate streams progress — the same shape you would use for real image resizing or JSON parsing.

Conclusion & next steps

  • Future for one async result, Stream for many over time, Isolate for real parallelism.
  • await is non-blocking; only isolate-hopping moves CPU work off the main thread.
  • Future.wait for parallel I/O, Isolate.run/compute for heavy CPU, spawn + ports for long-lived workers.

Try extending the final example: swap the synthetic loop for real package:image resizing, then add per-worker error handling with a Completer so one failed image does not kill the batch.

References / Sources