| |

Dart 54 🎯 High-Level Concurrency with Isolate.run for CPU-Bound Calculations

⏱️ Estimated read: 20 min | |

The previous chapter established that isolates are the correct tool for CPU-bound work and that Isolate.spawn is the low-level primitive for creating them. That primitive is verbose. Spawning an isolate, wiring up a ReceivePort, listening for the result, closing the port, and handling errors takes dozens of lines for what is conceptually a single question: “compute this and give me the answer.” The Isolate.run function exists to collapse that boilerplate into one call. It spawns an isolate, runs the function, sends the result back, closes the port, and terminates the isolate, all without the caller managing any of it. For the common case of a one-shot CPU-bound task, it is the correct API, and it replaces nearly every use of manual spawn for short-lived work.

Key point: Isolate.run is the high-level API for one-shot CPU-bound tasks. It handles the entire isolate lifecycle in one call. It is not for long-lived workers, streaming results, or repeated communication. For those, manual Isolate.spawn remains the correct choice.


Why Isolate.run exists

The boilerplate problem. Manual spawn requires the caller to create a ReceivePort, spawn the isolate with a SendPort, listen for the result, handle errors on the port, close the port, and optionally kill the isolate. That is eight or nine lines of infrastructure for a computation that might be a single function call. The infrastructure is not the point of the code, and it distracts from the logic.

The error-propagation problem. With manual spawn, an error thrown in the worker is not automatically propagated to the main isolate. The worker must catch it, package it into a message, send it, and the main isolate must recognize it as an error and rethrow it. This is easy to get wrong, and the failure mode is a hang: the worker throws, never sends a result, and the main isolate waits forever.

The lifecycle problem. A manually spawned isolate must be killed explicitly. If the main isolate forgets, the worker persists indefinitely. Isolate.run ties the isolate’s lifetime to the function’s execution: when the function returns, the isolate exits. There is no leak.

The result-serialization problem. The result of the computation must be copied back to the main isolate. With manual spawn, the developer writes the send call and relies on the copy semantics. Isolate.run handles the return value directly, so the function reads as if it were a normal function call.

The readability problem. Code that uses Isolate.run reads as sequential logic. The isolation is an implementation detail. Code that uses manual spawn reads as message-passing plumbing, and the actual computation is buried inside it.

The correctness problem. The manual pattern has subtle traps: forgetting to close the port, sending the wrong port type, failing to handle errors on the port, or racing between the result and the isolate’s termination. Isolate.run is the reviewed, tested implementation of the pattern, and using it avoids those traps.


a. The Isolate.run API

Isolate.run takes a callback and returns a future of the callback’s result. It is the entire public surface.

The signature

Future<R> Isolate.run<R>(FutureOr<R> Function() computation)

The callback takes no arguments and returns a value of type R. The return value is sent back to the caller. If the callback throws, the error is propagated to the future.

final result = await Isolate.run(() => heavyComputation(data));

The function runs in a new isolate. The result is returned as a future. The isolate exits when the function completes or throws.

The closure capture

Unlike Isolate.spawn, Isolate.run accepts a closure that can capture variables from the enclosing scope. The VM serializes the captured values and sends them to the new isolate along with the closure. This is why Isolate.run is more ergonomic than manual spawn: the developer does not have to package arguments into a message manually.

final data = loadData();
final threshold = 100;
final result = await Isolate.run(() {
  return data.where((x) => x > threshold).toList();
});

The data list and the threshold int are captured. The VM copies them into the new isolate. The function runs there and returns the filtered list.

The captured values must be sendable. Closures that capture ReceivePort, native handles, or other non-sendable values will fail at spawn time with an UnsupportedError.

final port = ReceivePort();
final result = await Isolate.run(() {
  return port.toString();  // Error: ReceivePort cannot be sent.
});

The return value

The return value must also be sendable. The VM copies it from the worker’s heap back to the main isolate’s heap. Large return values incur the copy cost.

final bigList = await Isolate.run(() => List.generate(1000000, (i) => i));
// bigList is copied back to the main isolate.

For large buffers, the return value should be wrapped in TransferableTypedData to avoid the copy.

The error propagation

If the callback throws, the error is caught by the runtime, sent to the main isolate, and rethrown from the returned future. The isolate exits either way.

try {
  await Isolate.run(() => throw StateError('boom'));
} catch (e) {
  print(e);  // StateError: boom
}

The stack trace is also propagated. The caller can catch it like any other asynchronous error.

The isolate lifecycle

Isolate.run spawns the isolate, runs the function, sends the result or error, and kills the isolate. The entire lifecycle is internal. The caller never sees an Isolate object.

// Conceptual expansion:
Future<R> isolateRun<R>(FutureOr<R> Function() computation) async {
  final port = ReceivePort();
  final isolate = await Isolate.spawn(_entry, [computation, port.sendPort]);
  try {
    final message = await port.first;
    if (message is _Error) rethrow;
    return message as R;
  } finally {
    port.close();
    isolate.kill();
  }
}

The real implementation is more efficient, but the shape is the same. The port is closed and the isolate is killed in a finally block, so both happen regardless of success or failure.

The async callback

The callback may return a Future. Isolate.run awaits it before sending the result.

final result = await Isolate.run(() async {
  final intermediate = await stepOne();
  return stepTwo(intermediate);
});

Inside the new isolate, the event loop is active, so await works normally. The isolate does not exit until the returned future completes.


b. When Isolate.run is appropriate

Isolate.run is designed for one specific case: a single computation that runs once and returns a single result. Understanding the boundaries helps identify when another API is needed.

The CPU-bound one-shot case

The canonical use case is a computation that will keep the CPU busy for a noticeable period: image decoding, large data parsing, cryptographic hashing, sorting millions of records, or a complex calculation. The work is done once, the result is returned, and the isolate exits.

final hash = await Isolate.run(() => sha256.convert(bytes));

The image, the data, or the bytes are captured. The hash is returned. The isolate exits.

The case against I/O-bound work

Isolate.run should not be used for I/O-bound work. Reading a file, making an HTTP request, or querying a database does not need an isolate, because the event loop already handles those operations without blocking. Wrapping them in Isolate.run adds the cost of spawning an isolate and copying the result, with no benefit.

// BAD: I/O does not need an isolate.
final response = await Isolate.run(() => http.get(url));
// GOOD:
final response = await http.get(url);

The rule is: if the work waits for an external resource, it belongs on the main isolate. If the work keeps the CPU busy, it belongs in an isolate.

The case against repeated tasks

Isolate.run spawns a new isolate every time. For a task that runs repeatedly, that overhead dominates. A pool of long-lived workers is the correct pattern for repeated tasks.

// BAD: spawns a new isolate per item.
for (final item in items) {
  await Isolate.run(() => process(item));
}

// GOOD: use a pool.
final pool = await WorkerPool.spawn(4);
for (final item in items) {
  pool.run(item);
}

The threshold depends on the task’s duration. If the task takes milliseconds, the spawn overhead is negligible. If the task takes microseconds, the spawn dominates and a pool is necessary.

The case against streaming results

Isolate.run returns one value. If the computation produces a stream of results over time, Isolate.run is the wrong API. A long-lived isolate with a SendPort supports streaming naturally.

// BAD: cannot stream with Isolate.run.
await Isolate.run(() async {
  for (final item in items) {
    yield process(item);  // Not supported.
  }
});

// GOOD: long-lived isolate.
final port = ReceivePort();
await Isolate.spawn(streamWorker, [port.sendPort, items]);
port.listen((result) => handle(result));

The streamWorker sends each result as it becomes available, and the main isolate processes them as they arrive.

The case against two-way communication

Isolate.run is one-directional: input through the closure, output through the return. If the isolate needs to receive more input while running, or if the main isolate needs to send it more data, a manual spawn with bidirectional ports is required.

// BAD: cannot receive more input with Isolate.run.
await Isolate.run(() {
  // No way to receive a second message.
  return result;
});

The threshold rule of thumb

A useful rule: if the computation takes less than a millisecond, do not use an isolate. If it takes more than a few milliseconds, an isolate is appropriate. The exact threshold depends on the platform and the relative cost of spawning versus blocking the main isolate for the duration.


c. Patterns built on Isolate.run

Isolate.run is a primitive. The patterns in this section compose it into higher-level structures.

The scatter-gather pattern

Scatter-gather splits a task into chunks, runs each chunk in a separate isolate, and combines the results. Isolate.run handles each chunk.

Future<List<R>> scatterGather<T, R>(
  List<T> items,
  R Function(List<T>) chunkWork,
  int parallel,
) async {
  final chunkSize = (items.length / parallel).ceil();
  final chunks = <List<T>>[];
  for (var i = 0; i < items.length; i += chunkSize) {
    final end = (i + chunkSize).clamp(0, items.length);
    chunks.add(items.sublist(i, end));
  }
  final results = await Future.wait(
    chunks.map((chunk) => Isolate.run(() => chunkWork(chunk))),
  );
  return results;
}

The Future.wait starts all isolates concurrently. Each returns its chunk’s result. The main isolate combines them.

The parallelism is bounded by the number of chunks, which should match the number of cores. More chunks than cores does not increase parallelism.

The pipeline pattern

A pipeline runs a sequence of stages, each in its own isolate, with the output of one feeding the next. Each stage uses Isolate.run for its one-shot work.

final parsed = await Isolate.run(() => parse(rawText));
final transformed = await Isolate.run(() => transform(parsed));
final formatted = await Isolate.run(() => format(transformed));

This is sequential: each stage waits for the previous. For true pipeline parallelism, where all stages run at once, a long-lived isolate per stage is needed. The Isolate.run version is appropriate when each stage’s work is itself substantial and the stages do not overlap.

The map-reduce pattern

Map-reduce applies a function to each item in parallel, then reduces the results. The map step uses Isolate.run per batch, and the reduce step runs on the main isolate.

Future<R> mapReduce<T, R>(
  List<T> items,
  R Function(T) map,
  R Function(R, R) reduce,
  R initial,
) async {
  final mapped = await Isolate.run(() => items.map(map).toList());
  return mapped.fold(initial, reduce);
}

The mapping runs in the worker. The reduction runs on the main isolate. If the reduction is heavy, it can also be moved to an isolate.

The timeout pattern

Isolate.run returns a future, so it composes with timeout. A computation that runs too long can be cancelled at the caller’s discretion.

try {
  final result = await Isolate.run(() => heavyCompute(data))
      .timeout(Duration(seconds: 5));
} on TimeoutException {
  // The future times out.
  // The isolate is still running and will be killed when it completes.
}

The timeout abandons the future, but the isolate continues until its function returns. The isolate is killed when the function completes. For truly cancellable work, a manual spawn with a kill message is required.

The fallback pattern

Isolate.run can be used as a fallback when the main isolate detects a heavy computation. The application can run a fast path on the main isolate and fall back to an isolate when the work exceeds a threshold.

Future<R> smartCompute<T, R>(T input, R Function(T) compute) async {
  final stopwatch = Stopwatch()..start();
  final fastResult = compute(input);
  if (stopwatch.elapsedMilliseconds < 5) {
    return fastResult;
  }
  return Isolate.run(() => compute(input));
}

The heuristic is crude, but the pattern is: try the main isolate first, then offload if it takes too long. In practice, the decision is made per workload, not per call.

The batch pattern

When many small tasks must be processed, batching them into one Isolate.run call amortizes the spawn cost across all of them.

final results = await Isolate.run(() {
  return items.map(process).toList();
});

One isolate, one spawn, many results. This is more efficient than one isolate per item.


Complete Example Session

// ============================================
// PART 1: THE BLOCKING PROBLEM
// ============================================

// A CPU-bound computation on the main isolate.
int countPrimes(int limit) {
  var count = 0;
  for (var i = 2; i < limit; i++) {
    if (_isPrime(i)) count++;
  }
  return count;
}

bool _isPrime(int n) {
  if (n < 2) return false;
  for (var i = 2; i * i <= n; i++) {
    if (n % i == 0) return false;
  }
  return true;
}

void main() {
  final result = countPrimes(10000000);  // Blocks for seconds.
  print(result);
}

// ============================================
// PART 2: THE Isolate.run FIX
// ============================================

import 'dart:isolate';

void main() async {
  final result = await Isolate.run(() => countPrimes(10000000));
  print(result);
}

// The computation runs in a new isolate.
// The main isolate remains responsive.

// ============================================
// PART 3: CAPTURING VARIABLES
// ============================================

void main() async {
  final limit = 10000000;
  final threshold = 100;
  final result = await Isolate.run(() {
    final count = countPrimes(limit);
    return count > threshold ? 'many' : 'few';
  });
  print(result);
}

// limit and threshold are captured and copied.
// The result is the string 'many' or 'few'.

// ============================================
// PART 4: ERROR PROPAGATION
// ============================================

void main() async {
  try {
    await Isolate.run(() {
      throw StateError('computation failed');
    });
  } on StateError catch (e) {
    print('Caught: $e');
  }
}

// The error is propagated to the main isolate.
// The isolate exits regardless.

// ============================================
// PART 5: ASYNC CALLBACK
// ============================================

void main() async {
  final result = await Isolate.run(() async {
    final intermediate = await stepOne();
    return stepTwo(intermediate);
  });
}

// Inside the isolate, await works normally.
// The isolate waits for the future before returning.

// ============================================
// PART 6: SCATTER-GATHER
// ============================================

Future<int> parallelCountPrimes(int limit, int parallel) async {
  final chunkSize = limit ~/ parallel;
  final futures = <Future<int>>[];
  for (var i = 0; i < parallel; i++) {
    final start = i * chunkSize;
    final end = (i == parallel - 1) ? limit : start + chunkSize;
    futures.add(Isolate.run(() => countPrimesInRange(start, end)));
  }
  final results = await Future.wait(futures);
  return results.reduce((a, b) => a + b);
}

// Each chunk runs in its own isolate.
// The results are summed on the main isolate.

// ============================================
// PART 7: BATCHING
// ============================================

Future<List<String>> batchProcess(List<String> items) async {
  return Isolate.run(() {
    return items.map(processItem).toList();
  });
}

// One spawn, many items.
// More efficient than one spawn per item.

// ============================================
// PART 8: THE TIMEOUT PATTERN
// ============================================

void main() async {
  try {
    final result = await Isolate.run(() => countPrimes(100000000))
        .timeout(Duration(seconds: 2));
  } on TimeoutException {
    print('Computation took too long');
  }
}

// The future times out.
// The isolate continues until the function returns.

// ============================================
// PART 9: LARGE RETURN VALUES
// ============================================

void main() async {
  final bytes = await Isolate.run(() {
    final buffer = Uint8List(1024 * 1024);
    return TransferableTypedData.fromList([buffer]);
  });
  final data = bytes.materialize().asUint8List();
}

// TransferableTypedData avoids the copy on the way back.
// The sender's reference is emptied.

// ============================================
// PART 10: WHEN TO USE Isolate.run
// ============================================

// USE Isolate.run FOR:
// - One-shot CPU-bound computations
// - Tasks that take more than a few milliseconds
// - Single input, single output
// - When the boilerplate of Isolate.spawn is unwanted

// DO NOT USE Isolate.run FOR:
// - I/O-bound work (the event loop handles it)
// - Repeated tasks (use a pool)
// - Streaming results (use a long-lived isolate)
// - Two-way communication (use bidirectional ports)
// - Tasks shorter than a millisecond (overhead dominates)

The ten parts progressed from the blocking problem through Isolate.run, variable capture, error propagation, async callbacks, scatter-gather, batching, timeouts, large return values, and the decision guide. The same prime-counting example ran through every part, showing how Isolate.run handles each scenario.


Quick Reference

Isolate.run Signature

ParameterTypeDescription
computationFutureOr<R> Function()The function to run in the new isolate
ReturnsFuture<R>The result or error from the computation

When to Use Which API

ScenarioAPI
One-shot CPU-bound taskIsolate.run
Repeated tasksWorker pool with Isolate.spawn
Streaming resultsLong-lived isolate with SendPort
Two-way communicationBidirectional ports
I/O-bound workNo isolate

Error and Result Handling

OutcomeBehavior
Function returnsFuture completes with value
Function throwsFuture completes with error
Function returns a futureFuture is awaited, then result sent
Isolate crashesFuture completes with error

Performance Thresholds

Task DurationRecommendation
< 1 msRun on main isolate
1–10 msBorderline; measure
> 10 msUse Isolate.run
RepeatedUse a worker pool

Best Practices

✅ Do This:

// Use Isolate.run for one-shot CPU-bound work.
final result = await Isolate.run(() => heavyCompute(data));  // ✅

// Let errors propagate naturally.
try { await Isolate.run(() => mayThrow()); } catch (e) {}  // ✅

// Capture variables instead of packaging messages.
final result = await Isolate.run(() => compute(data, threshold));  // ✅

// Transfer large buffers.
return TransferableTypedData.fromList([buffer]);  // ✅

// Batch many small items into one call.
await Isolate.run(() => items.map(process).toList());  // ✅

// Use Future.wait for scatter-gather.
await Future.wait(chunks.map((c) => Isolate.run(() => work(c))));  // ✅

❌ Don’t Do This:

// Don't use Isolate.run for I/O.
await Isolate.run(() => http.get(url));  // ❌ Event loop handles it

// Don't spawn per item for many items.
for (final item in items) {
  await Isolate.run(() => process(item));  // ❌ Overhead dominates
}

// Don't capture non-sendable values.
final port = ReceivePort();
await Isolate.run(() => port.toString());  // ❌ Not sendable

// Don't use Isolate.run for streaming.
await Isolate.run(() { for (final x in items) yield x; });  // ❌ Not supported

// Don't use it for tasks under a millisecond.
await Isolate.run(() => 1 + 1);  // ❌ Overhead > work

// Don't expect the isolate to be cancellable.
// The function runs to completion.  // ❌

Common Pitfalls

PitfallWhy It HappensFix
UnsupportedErrorCapturing non-sendable valueOnly capture sendable values
Hang on errorError not propagatedIsolate.run handles it
Slow for repeated tasksSpawn overhead per callUse a worker pool
Large return copyReturn value copiedUse TransferableTypedData
Timeout does not stop workIsolate runs to completionManual spawn for cancellation
Captured variable mutatedCaptured value is a copyMutations do not propagate back
Slow I/OIsolate used unnecessarilyRun I/O on main isolate
Spawn failureFunction references main-only stateKeep the closure self-contained

Real-World Examples

1. Hash a File

final hash = await Isolate.run(() => sha256.convert(bytes));

2. Decode an Image

final pixels = await Isolate.run(() => decodeImage(bytes));

3. Sort a Large List

final sorted = await Isolate.run(() => List.of(data)..sort());

4. Parse a Large JSON

final parsed = await Isolate.run(() => jsonDecode(raw));

5. Count Primes in Parallel

final counts = await Future.wait([
  Isolate.run(() => countPrimesInRange(0, 5000000)),
  Isolate.run(() => countPrimesInRange(5000000, 10000000)),
]);

6. Batch Process Items

final results = await Isolate.run(() => items.map(process).toList());

7. Transfer a Large Buffer

final data = await Isolate.run(() {
  final buffer = Uint8List(1024 * 1024);
  return TransferableTypedData.fromList([buffer]);
});

8. Error Handling

try {
  await Isolate.run(() => mayThrow());
} on StateError catch (e) {
  print(e);
}

9. Timeout

await Isolate.run(() => heavy()).timeout(Duration(seconds: 5));

10. Map-Reduce

final mapped = await Isolate.run(() => items.map(map).toList());
final reduced = mapped.fold(initial, reduce);

Visual

Isolate.run Lifecycle

┌────────────────────────────────────────────────────────────┐
│  Isolate.run LIFECYCLE                                     │
│                                                            │
│  Main Isolate:                                             │
│  ┌──────────────────────────────────────────────────┐     │
│  │  await Isolate.run(() => compute(data))           │     │
│  └────────────────────────┬─────────────────────────┘     │
│                           │                                │
│                           v                                │
│  ┌──────────────────────────────────────────────────┐     │
│  │  1. Captures closure and captured variables       │     │
│  │  2. Spawns new isolate                            │     │
│  │  3. Sends closure and captured values             │     │
│  └────────────────────────┬─────────────────────────┘     │
│                           │                                │
│                           v                                │
│  Worker Isolate:                                           │
│  ┌──────────────────────────────────────────────────┐     │
│  │  4. Runs the closure                              │     │
│  │  5. Awaits if the closure returns a future        │     │
│  │  6. Sends result or error back                    │     │
│  │  7. Isolate exits                                 │     │
│  └────────────────────────┬─────────────────────────┘     │
│                           │                                │
│                           v                                │
│  Main Isolate:                                             │
│  ┌──────────────────────────────────────────────────┐     │
│  │  8. Receives result or error                      │     │
│  │  9. Completes the future                          │     │
│  │ 10. Port closed, isolate killed                   │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The entire lifecycle is internal to Isolate.run.          │
│                                                            │
└────────────────────────────────────────────────────────────┘

When to Use Each API

┌────────────────────────────────────────────────────────────┐
│  CHOOSING A CONCURRENCY API                                │
│                                                            │
│  Is the work I/O-bound?                                    │
│    YES → Do not use an isolate.                            │
│          Use async/await on the main isolate.              │
│    NO  → Continue.                                         │
│                                                            │
│  Is the work one-shot (single input, single output)?       │
│    YES → Use Isolate.run.                                  │
│    NO  → Continue.                                         │
│                                                            │
│  Does the work produce a stream of results?                │
│    YES → Use a long-lived isolate with SendPort.           │
│    NO  → Continue.                                         │
│                                                            │
│  Does the work need two-way communication?                 │
│    YES → Use Isolate.spawn with bidirectional ports.       │
│    NO  → Continue.                                         │
│                                                            │
│  Does the work run repeatedly?                             │
│    YES → Use a worker pool.                                │
│    NO  → Use Isolate.run.                                  │
│                                                            │
└────────────────────────────────────────────────────────────┘

Scatter-Gather

┌────────────────────────────────────────────────────────────┐
│  SCATTER-GATHER WITH Isolate.run                           │
│                                                            │
│  Main Isolate:                                             │
│  ┌──────────────────────────────────────────────────┐     │
│  │  items = [1, 2, 3, ..., 1000000]                  │     │
│  │  chunks = split(items, 4)                         │     │
│  └────────────────────────┬─────────────────────────┘     │
│                           │                                │
│       ┌───────────────────┼───────────────────┐            │
│       │                   │                   │            │
│       v                   v                   v            │
│  ┌─────────┐         ┌─────────┐         ┌─────────┐     │
│  │Run(chunk1)│       │Run(chunk2)│       │Run(chunk3)│   │
│  │Isolate 1 │       │Isolate 2 │       │Isolate 3 │     │
│  │Core 1    │       │Core 2    │       │Core 3    │     │
│  └────┬────┘         └────┬────┘         └────┬────┘     │
│       │                   │                   │            │
│       └───────────────────┼───────────────────┘            │
│                           │                                │
│                           v                                │
│  ┌──────────────────────────────────────────────────┐     │
│  │  Future.wait([r1, r2, r3])                        │     │
│  │  Combine results.                                 │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  True parallelism across cores.                            │
│  Main isolate remains responsive.                          │
│                                                            │
└────────────────────────────────────────────────────────────┘

The Capture and Copy

┌────────────────────────────────────────────────────────────┐
│  CLOSURE CAPTURE AND RESULT COPY                           │
│                                                            │
│  Main Isolate:                                             │
│  ┌──────────────────────────────────────────────────┐     │
│  │  final data = [1, 2, 3, ...];                     │     │
│  │  final threshold = 100;                           │     │
│  │  await Isolate.run(() {                           │     │
│  │    return data.where((x) => x > threshold);       │     │
│  │  });                                              │     │
│  └────────────────────────┬─────────────────────────┘     │
│                           │                                │
│                           v                                │
│  ┌──────────────────────────────────────────────────┐     │
│  │  data and threshold are COPIED to worker heap.    │     │
│  └────────────────────────┬─────────────────────────┘     │
│                           │                                │
│                           v                                │
│  Worker Isolate:                                           │
│  ┌──────────────────────────────────────────────────┐     │
│  │  Runs the closure.                                │     │
│  │  Returns the filtered list.                       │     │
│  └────────────────────────┬─────────────────────────┘     │
│                           │                                │
│                           v                                │
│  ┌──────────────────────────────────────────────────┐     │
│  │  Result is COPIED back to main isolate.           │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  Capture and result are both copies.                       │
│  For large payloads, use TransferableTypedData.            │
│                                                            │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
Isolate.runHigh-level API for one-shot CPU-bound tasks
SignatureFuture<R> Isolate.run<R>(FutureOr<R> Function())
Closure captureCaptured variables are copied to the worker
Return valueCopied back to the main isolate
Error handlingErrors propagated to the returned future
LifecycleSpawn, run, return, kill — all internal
Async callbackAwaited before the result is sent
Large payloadsWrap in TransferableTypedData
ParallelismFuture.wait with multiple Isolate.run calls
ThresholdUse when work exceeds a few milliseconds

Key takeaways:

  • Isolate.run collapses the manual spawn boilerplate into one call. It handles the port, the message, the result, the error, the port close, and the isolate kill. The caller writes only the computation.
  • Closures can capture variables. Unlike Isolate.spawn, Isolate.run accepts a closure that captures values from the enclosing scope. The captured values are copied to the worker’s heap.
  • Errors propagate naturally. A thrown error becomes a rejected future on the main isolate. There is no need to package errors into messages or to handle hangs from a worker that threw without sending a result.
  • The callback may be async. If the closure returns a future, it is awaited inside the isolate, and the isolate remains alive until the future completes.
  • Isolate.run is for one-shot CPU-bound work. It is the wrong API for I/O, repeated tasks, streaming results, and two-way communication. Each of those has a better-fitting API.
  • The threshold matters. Tasks shorter than a millisecond do not benefit from an isolate because the spawn overhead dominates the work. Tasks longer than a few milliseconds benefit significantly.
  • Large payloads should be transferred. The capture and the return value are copies. For large buffers, TransferableTypedData avoids the copy and keeps the cost constant.
  • Scatter-gather composes Isolate.run with Future.wait. Splitting work into chunks and running each chunk in its own isolate is the standard pattern for parallel CPU-bound work.
  • Batching amortizes the spawn cost. One Isolate.run call that processes many items is more efficient than one call per item, because the spawn cost is paid once.

Remember: Isolate.run is the high-level API for CPU-bound concurrency in Dart. It spawns an isolate, runs the given closure, captures the result or error, and kills the isolate, all in a single call. The closure can capture variables, and the captured values are copied to the worker. The return value is copied back. Errors propagate naturally. The function is the correct choice for one-shot tasks that would otherwise block the main isolate for more than a few milliseconds. It is the wrong choice for I/O, which the event loop already handles; for repeated tasks, which need a pool; for streaming, which needs a long-lived isolate; and for two-way communication, which needs bidirectional ports. Used within its scope, it makes concurrent code read like sequential code, and removes the boilerplate and error-prone plumbing that manual spawning requires.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!