Watch the video (10 min)
On this page23 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. Two Kinds of Slow
  5. 2. Don't Block the Event Loop
  6. 3. Move CPU Work to an Isolate
  7. 4. Await What You Start
  8. 5. Fire-and-Forget on Purpose
  9. 6. Handle Every Async Error
  10. 7. Cancel Subscriptions, Close Controllers
  11. 8. Race Conditions on Shared State
  12. 9. Keep Isolate Messages Small
  13. 10. Measure Before Optimizing
  14. 11. A Real-World Example
  15. Code Examples
  16. Common Beginner Mistakes
  17. Best Practices
  18. Mini Challenge
  19. Practice Questions
  20. Practice Answers
  21. Quick Revision
  22. Key Takeaways
  23. What's Next?

Lecture 100 — Concurrency Best Practices

Category16 — Concurrency & Isolates
Lecture100 of 140
Estimated video length10 min
TargetDart 3.12.2
LevelIntermediate

Knowing futures, streams, and isolates is not the same as using them safely. This lecture collects the rules that keep concurrent Dart programs fast, correct, and free of silent bugs.

Contents

Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways

Every output and error message in this lecture is real. Each one was produced by running the code shown on Dart 3.12.2. Timing lines are approximate — read 1492 ms as "about one and a half seconds"; your numbers will differ.


Prerequisites

Students should already understand, from Lectures 087–099:

  • the event loop, async/await, and try/catch around await (087–090)
  • Future.wait and Stopwatch (091)
  • listen, StreamSubscription.cancel(), and StreamController (092–093)
  • Isolate.run and how isolates copy messages (097–099)

Connection With Previous Lectures

Real programs mix every tool from this category: a banking app loads data, crunches numbers, listens to notifications, and updates one balance from several places. That raises questions no single lecture answered: Should this work use await or an isolate? What happens to a future nobody awaits? Can two async functions spoil one variable when Dart has only one thread? This lecture answers them with eight rules.


Learning Objectives

By the end of this lecture you will be able to:

  • Decide between async/await and an isolate for a given task
  • Recognize code that blocks the event loop
  • Await every future you start, or mark it with unawaited on purpose
  • Make sure every async error has a handler
  • Cancel subscriptions and close controllers you no longer need
  • Spot and fix a race condition on shared state in async code
  • Keep isolate messages small
  • Measure with Stopwatch before optimizing

1. Two Kinds of Slow

Analogy — a kitchen with one chef. While the oven bakes, the chef cooks other dishes. While the chef chops a mountain of onions, nobody else is served — that needs a second cook.

Slow work comes in two kinds, and each has its own tool:

KindWhat it doesExamplesTool
I/O-boundmostly waitsnetwork, files, timersasync/await
CPU-boundmostly calculatesbig loops, parsing huge dataan isolate

Ask: is the program waiting, or working?


2. Don't Block the Event Loop

A timer is due after 100 ms, but a long loop is running:

dart
int sumTo(int n) {
  var total = 0;
  for (var i = 1; i <= n; i++) {
    total += i;
  }
  return total;
}

void main() {
  var stopwatch = Stopwatch()..start();
  Future.delayed(Duration(milliseconds: 100), () {
    print('Timer fired at ${stopwatch.elapsedMilliseconds} ms');
  });
  print('Sum: ${sumTo(2000000000)}');
  print('Loop done at ${stopwatch.elapsedMilliseconds} ms');
}
output
Sum: 2000000001000000000
Loop done at 1492 ms
Timer fired at 1493 ms

The timer was 1.4 seconds late: while the loop runs, the event loop cannot take the next task. In an app, buttons and animations freeze the same way.


3. Move CPU Work to an Isolate

Same loop, same timer, but the loop runs in Isolate.run:

dart
import 'dart:isolate';

int sumTo(int n) {
  var total = 0;
  for (var i = 1; i <= n; i++) {
    total += i;
  }
  return total;
}

Future<void> main() async {
  var stopwatch = Stopwatch()..start();
  Future.delayed(Duration(milliseconds: 100), () {
    print('Timer fired at ${stopwatch.elapsedMilliseconds} ms');
  });
  var sum = await Isolate.run(() => sumTo(2000000000));
  print('Sum: $sum');
  print('Loop done at ${stopwatch.elapsedMilliseconds} ms');
}
output
Timer fired at 103 ms
Sum: 2000000001000000000
Loop done at 1502 ms

Same total time, but the main isolate stayed free and the timer fired on time.


4. Await What You Start

A future that nobody awaits runs on its own. Its result arrives after the code that depended on it:

dart
Future<void> saveMarks(String name) async {
  await Future.delayed(Duration(seconds: 1));
  print('Saved marks for $name');
}

Future<void> main() async {
  saveMarks('Ali');
  print('All work finished');
}
output
All work finished
Saved marks for Ali

The program claims to be finished before the save happened. The unawaited_futures lint (Lecture 089) flags this line with Missing an 'await' for the 'Future' computed by this expression. Write await saveMarks('Ali'); and the order is right.


5. Fire-and-Forget on Purpose

Sometimes you don't want to wait — logging a visit should not delay the page. Say so with unawaited from dart:async, and attach an error handler, since no await will catch it:

dart
import 'dart:async';

Future<void> logVisit(String name) async {
  await Future.delayed(Duration(milliseconds: 500));
  print('Logged visit by $name');
}

Future<void> main() async {
  unawaited(
    logVisit('Sara').catchError((Object e) {
      print('Log failed: $e');
    }),
  );
  print('Page shown');
}
output
Page shown
Logged visit by Sara

The lint stays quiet, and readers see the choice was deliberate.


6. Handle Every Async Error

A try/catch only catches errors from futures it awaits:

dart
Future<void> saveMarks(String name) async {
  await Future.delayed(Duration(seconds: 1));
  throw Exception('Disk full');
}

Future<void> main() async {
  try {
    saveMarks('Ali');
  } catch (e) {
    print('Caught: $e');
  }
  print('main is done');
}
output
main is done
Unhandled exception:
Exception: Disk full
#0      saveMarks (file:///…/s4.dart:3:3)
<asynchronous suspension>

The try finished long before the error happened, so nothing handled it. With await inside the try, you get Caught: Exception: Disk full. Every future needs a handler.


7. Cancel Subscriptions, Close Controllers

A stream keeps delivering until you stop it. When you no longer need values, cancel:

dart
Future<void> main() async {
  var readings = Stream.periodic(Duration(milliseconds: 300), (i) => 20.0 + i);
  var subscription = readings.listen((t) => print('Temperature: $t'));
  await Future.delayed(Duration(seconds: 1));
  await subscription.cancel();
  print('Stopped listening');
}
output
Temperature: 20.0
Temperature: 21.0
Temperature: 22.0
Stopped listening

Without cancel() this stream would tick forever. Likewise, whoever creates a StreamController must close() it, or listeners never hear "done":

dart
import 'dart:async';

Future<void> main() async {
  var controller = StreamController<String>();
  controller.stream.listen(
    (message) => print(message),
    onDone: () => print('Chat closed'),
  );
  controller.add('Ali joined');
  controller.add('Sara joined');
  await controller.close();
}
output
Ali joined
Sara joined
Chat closed

Delete the close() line and Chat closed never prints — with no error to warn you.


8. Race Conditions on Shared State

One thread does not mean no races. Every await is a pause where other code can run. Here, Ali and Sara withdraw from a shared balance of 1000 at the same time:

dart
var balance = 1000;

Future<void> withdraw(String who, int amount) async {
  var current = balance; // 1. read
  await Future.delayed(Duration(milliseconds: 100)); // 2. bank checks
  if (current >= amount) {
    balance = current - amount; // 3. write
    print('$who took $amount, balance $balance');
  } else {
    print('$who refused');
  }
}

Future<void> main() async {
  await Future.wait([withdraw('Ali', 700), withdraw('Sara', 600)]);
  print('Final balance: $balance');
}
output
Ali took 700, balance 300
Sara took 600, balance 400
Final balance: 400

1300 was paid out of 1000, and the bank still shows 400. Both read 1000 before either wrote, so Sara's write erased Ali's. This is a race condition: the result depends on how the steps interleave.

The fix is to sequence the operations, so one finishes before the next begins (same withdraw as above):

dart
Future<void> main() async {
  await withdraw('Ali', 700);
  await withdraw('Sara', 600);
  print('Final balance: $balance');
}
output
Ali took 700, balance 300
Sara refused
Final balance: 300

A second fix: keep read, check, and write together with no await between them — wait first, then touch balance in one uninterrupted step.


9. Keep Isolate Messages Small

Everything an isolate receives is copied. Here the isolate does almost nothing, so the time is all copying:

dart
import 'dart:isolate';

Future<int> countAll(List<Map<String, Object>> students) =>
    Isolate.run(() => students.length);

Future<int> countOne(int size) => Isolate.run(() => size);

Future<void> main() async {
  var students = [
    for (var i = 0; i < 1000000; i++) {'name': 'Student $i', 'marks': i % 101},
  ];

  var stopwatch = Stopwatch()..start();
  var count = await countAll(students);
  print('Sent 1,000,000 maps: $count in ${stopwatch.elapsedMilliseconds} ms');

  stopwatch.reset();
  var count2 = await countOne(students.length);
  print('Sent one int: $count2 in ${stopwatch.elapsedMilliseconds} ms');
}
output
Sent 1,000,000 maps: 1000000 in 676 ms
Sent one int: 1000000 in 0 ms

Send only what the isolate needs, and return a summary rather than a copy of the data.


10. Measure Before Optimizing

Starting an isolate has a cost. For small work it is slower than doing the work directly:

dart
import 'dart:isolate';

double average(List<int> marks) =>
    marks.fold(0, (sum, m) => sum + m) / marks.length;

Future<void> main() async {
  var marks = [80, 75, 92, 68, 88];

  var stopwatch = Stopwatch()..start();
  var direct = average(marks);
  print('Direct: $direct in ${stopwatch.elapsedMicroseconds} µs');

  stopwatch.reset();
  var viaIsolate = await Isolate.run(() => average(marks));
  print('Isolate: $viaIsolate in ${stopwatch.elapsedMicroseconds} µs');
}
output
Direct: 80.6 in 371 µs
Isolate: 80.6 in 5374 µs

The isolate was ten times slower. Measure first; use an isolate only for clearly heavy work.


11. A Real-World Example

Sara's app loads each month's payments (I/O), totals them in an isolate (CPU, one number back), and updates her balance one month at a time. A failing month doesn't stop the rest:

dart
import 'dart:isolate';

var balance = 60000.0;

Future<List<double>> loadPayments(String month) async {
  await Future.delayed(Duration(milliseconds: 300));
  if (month == 'Mar') throw Exception('Server busy');
  return List.filled(100000, 0.25);
}

double total(List<double> amounts) => amounts.fold(0.0, (sum, a) => sum + a);

Future<void> chargeMonth(String month) async {
  try {
    var payments = await loadPayments(month);
    var spent = await Isolate.run(() => total(payments));
    balance -= spent;
    print('$month: spent $spent, balance $balance');
  } on Exception catch (e) {
    print('$month skipped: $e');
  }
}

Future<void> main() async {
  var stopwatch = Stopwatch()..start();
  for (var month in ['Jan', 'Feb', 'Mar']) {
    await chargeMonth(month);
  }
  print('Done in ${stopwatch.elapsedMilliseconds} ms');
}
output
Jan: spent 25000.0, balance 35000.0
Feb: spent 25000.0, balance 10000.0
Mar skipped: Exception: Server busy
Done in 942 ms

Every future is awaited, every error caught, and balance changes in strict order.


Code Examples

Example 1 — Await the Save

dart
Future<void> saveMarks(String name) async {
  await Future.delayed(Duration(milliseconds: 300));
  print('Saved marks for $name');
}

Future<void> main() async {
  await saveMarks('Ali');
  print('All work finished');
}
output
Saved marks for Ali
All work finished

Example 2 — Start Early, Catch at the Await

dart
Future<int> loadMarks() async {
  await Future.delayed(Duration(milliseconds: 300));
  throw Exception('Server down');
}

Future<void> main() async {
  var marksFuture = loadMarks();
  print('Loading...');
  try {
    print(await marksFuture);
  } catch (e) {
    print('Caught: $e');
  }
}
output
Loading...
Caught: Exception: Server down

Starting early is fine if the future is awaited inside a try later.

Example 3 — Deliberate unawaited

dart
import 'dart:async';

Future<void> sendAnalytics() async {
  await Future.delayed(Duration(milliseconds: 200));
  throw Exception('Offline');
}

Future<void> main() async {
  unawaited(sendAnalytics().catchError((Object e) => print('Ignored: $e')));
  print('Screen ready');
}
output
Screen ready
Ignored: Exception: Offline

Example 4 — Cancel After a Few Values

dart
Future<void> main() async {
  var ticks = Stream.periodic(Duration(milliseconds: 200), (i) => i + 1);
  var subscription = ticks.listen((n) => print('Tick $n'));
  await Future.delayed(Duration(milliseconds: 700));
  await subscription.cancel();
  print('Cancelled');
}
output
Tick 1
Tick 2
Tick 3
Cancelled

Example 5 — Close So await for Ends

dart
import 'dart:async';

Future<void> main() async {
  var controller = StreamController<int>();
  controller.add(80);
  controller.add(95);
  unawaited(controller.close());
  await for (var mark in controller.stream) {
    print('Mark: $mark');
  }
  print('All marks read');
}
output
Mark: 80
Mark: 95
All marks read

close() is not awaited: its future completes only after the loop below reads everything.

Example 6 — A Heavy Loop in an Isolate

dart
import 'dart:isolate';

int countEven(int n) {
  var count = 0;
  for (var i = 0; i < n; i++) {
    if (i % 2 == 0) count++;
  }
  return count;
}

Future<void> main() async {
  var result = await Isolate.run(() => countEven(500000000));
  print('Even numbers: $result');
}
output
Even numbers: 250000000

Example 7 — Student Marks

dart
import 'dart:isolate';

double average(List<int> marks) =>
    marks.fold(0, (sum, m) => sum + m) / marks.length;

Future<void> main() async {
  var students = [
    for (var i = 0; i < 300000; i++) (name: 'Student $i', marks: 60 + i % 41),
  ];
  var marksOnly = [for (var s in students) s.marks];
  var result = await Isolate.run(() => average(marksOnly));
  print('Average of ${marksOnly.length} students: $result');
}
output
Average of 300000 students: 79.99981

Only the marks cross into the isolate — not the names — and one double comes back.

Example 8 — Shopping Total

dart
Future<double> price(String item, double value) async {
  await Future.delayed(Duration(milliseconds: 300));
  if (item == 'Lamp') throw Exception('$item out of stock');
  return value;
}

Future<void> main() async {
  try {
    var prices = await Future.wait([price('Book', 450.0), price('Pen', 50.0)]);
    print('Total: ${prices.reduce((a, b) => a + b)}');
    await price('Lamp', 1200.0);
  } catch (e) {
    print('Order problem: $e');
  }
}
output
Total: 500.0
Order problem: Exception: Lamp out of stock

Prices are I/O, so they load with await, in parallel, inside the try.

Example 9 — Bank Balance in Order

dart
var balance = 500;

Future<void> change(String who, int amount) async {
  await Future.delayed(Duration(milliseconds: 100));
  balance += amount;
  print('$who: ${amount > 0 ? '+' : ''}$amount → $balance');
}

Future<void> main() async {
  await change('Zayan', 200);
  await change('Ali', -300);
  await change('Sara', 150);
  print('Final balance: $balance');
}
output
Zayan: +200 → 700
Ali: -300 → 400
Sara: +150 → 550
Final balance: 550

Each change finishes before the next starts, so none is lost.

Example 10 — Temperature Sensor with an Error

dart
import 'dart:async';

Future<void> main() async {
  var controller = StreamController<double>();
  var subscription = controller.stream.listen(
    (t) => print('Temperature: $t'),
    onError: (Object e) => print('Sensor error: $e'),
  );
  controller.add(24.5);
  controller.addError(Exception('Wire loose'));
  controller.add(25.0);
  await Future.delayed(Duration(milliseconds: 100));
  await subscription.cancel();
  await controller.close();
  print('Sensor off');
}
output
Temperature: 24.5
Sensor error: Exception: Wire loose
Temperature: 25.0
Sensor off

Common Beginner Mistakes

1. Using async to speed up heavy calculation

With sumTo from Step 2 marked async and awaited, the timer still fired at 1478 ms. async moves no work anywhere — use an isolate.

2. A try around a future that is not awaited

The error escapes the try and crashes the program, as in Step 6. Put await inside the try.

3. Believing one thread means no races

Each await lets other code run. Read, await, write loses updates (Step 8).

4. Forgetting close() or cancel()

No error appears — onDone never runs and a periodic stream keeps working.

5. Using an isolate for tiny work

Five marks took 0.4 ms directly and 5 ms in an isolate (Step 10).

6. Sending a whole object graph to an isolate

Copying a million maps cost about 700 ms before any work started (Step 9).


Best Practices

  • Keep each piece of work on the main isolate short.
  • Turn on the unawaited_futures lint.
  • Pair every listen with a cancel, and every StreamController with a close.
  • Sequence operations that touch the same data.
  • Send small messages to isolates and return summaries.
  • Measure with Stopwatch before and after any optimization.

Mini Challenge

Zayan and Sara each add 100 points to a shared score of 0. The buggy version reads the score, waits 100 ms, then writes current + 100. Show the bug with Future.wait, then fix it so the final score is 200.

Try it yourself before opening the solution.

Show solution
dart
var score = 0;

Future<void> addPoints(String who) async {
  var current = score;
  await Future.delayed(Duration(milliseconds: 100));
  score = current + 100;
  print('$who → $score');
}

Future<void> main() async {
  await Future.wait([addPoints('Zayan'), addPoints('Sara')]);
  print('Buggy score: $score');

  score = 0;
  await addPoints('Zayan');
  await addPoints('Sara');
  print('Fixed score: $score');
}
output
Zayan → 100
Sara → 100
Buggy score: 100
Zayan → 100
Sara → 200
Fixed score: 200

In parallel, both read 0 before either writes. Sequenced, the second read sees 100.


Practice Questions

Solve each one before looking at the answers.

Beginner

  1. For each task, say I/O-bound or CPU-bound: downloading a file, sorting ten million numbers, waiting 2 seconds, resizing a large photo.

  2. What does this print, and in what order?

    dart
    Future<void> greet() async {
      await Future.delayed(Duration(milliseconds: 100));
      print('Hello');
    }
    
    Future<void> main() async {
      greet();
      print('Bye');
    }
  3. Fix Question 2 so Hello prints before Bye.

Application

  1. fetchBalance() throws Exception('Timeout') after 200 ms. Start it, print Waiting..., then await it in a try and print Failed: Exception: Timeout.
  2. Listen to Stream.periodic(Duration(milliseconds: 250), (i) => i) and cancel after about 600 ms. Print each value and then Done.
  3. Create a StreamController<String>, listen with onDone: () => print('Finished'), add 'Ali', and close it.
  4. Use Isolate.run to count the numbers below 100,000,000 that are divisible by 3.

Challenge

  1. Two deposits of 50 into balance = 100 each read, wait 100 ms, then write. Show the lost update, then fix it by changing only main.
  2. Rewrite deposit from Question 8 so it works correctly even with Future.wait.
  3. Write addUp(List<int> numbers) and time it on [10, 20, 30] directly and with Isolate.run. Which is faster, and what does that tell you?

Practice Answers

Question 1 — Solution

Downloading: I/O-bound. Sorting ten million numbers: CPU-bound. Waiting: I/O-bound. Resizing a large photo: CPU-bound.

Question 2 — Solution

output
Bye
Hello

greet() is not awaited, so Bye comes first.

Question 3 — Solution

dart
Future<void> greet() async {
  await Future.delayed(Duration(milliseconds: 100));
  print('Hello');
}

Future<void> main() async {
  await greet();
  print('Bye');
}
output
Hello
Bye

Question 4 — Solution

dart
Future<double> fetchBalance() async {
  await Future.delayed(Duration(milliseconds: 200));
  throw Exception('Timeout');
}

Future<void> main() async {
  var balanceFuture = fetchBalance();
  print('Waiting...');
  try {
    print(await balanceFuture);
  } catch (e) {
    print('Failed: $e');
  }
}
output
Waiting...
Failed: Exception: Timeout

Question 5 — Solution

dart
Future<void> main() async {
  var stream = Stream.periodic(Duration(milliseconds: 250), (i) => i);
  var subscription = stream.listen(print);
  await Future.delayed(Duration(milliseconds: 600));
  await subscription.cancel();
  print('Done');
}
output
0
1
Done

Question 6 — Solution

dart
import 'dart:async';

Future<void> main() async {
  var controller = StreamController<String>();
  controller.stream.listen(print, onDone: () => print('Finished'));
  controller.add('Ali');
  await controller.close();
}
output
Ali
Finished

Question 7 — Solution

dart
import 'dart:isolate';

int countDivisibleBy3(int n) {
  var count = 0;
  for (var i = 0; i < n; i++) {
    if (i % 3 == 0) count++;
  }
  return count;
}

Future<void> main() async {
  var count = await Isolate.run(() => countDivisibleBy3(100000000));
  print('Divisible by 3: $count');
}
output
Divisible by 3: 33333334

0 counts too, which is why the answer ends in 4.

Question 8 — Solution

dart
var balance = 100;

Future<void> deposit(int amount) async {
  var current = balance;
  await Future.delayed(Duration(milliseconds: 100));
  balance = current + amount;
}

Future<void> main() async {
  await Future.wait([deposit(50), deposit(50)]);
  print('Buggy: $balance');

  balance = 100;
  await deposit(50);
  await deposit(50);
  print('Fixed: $balance');
}
output
Buggy: 150
Fixed: 200

Question 9 — Solution

Wait first, then read and write with no await in between:

dart
var balance = 100;

Future<void> deposit(int amount) async {
  await Future.delayed(Duration(milliseconds: 100));
  balance = balance + amount;
}

Future<void> main() async {
  await Future.wait([deposit(50), deposit(50)]);
  print('Balance: $balance');
}
output
Balance: 200

Question 10 — Solution

dart
import 'dart:isolate';

int addUp(List<int> numbers) => numbers.reduce((a, b) => a + b);

Future<void> main() async {
  var numbers = [10, 20, 30];
  var stopwatch = Stopwatch()..start();
  print('Direct: ${addUp(numbers)} in ${stopwatch.elapsedMicroseconds} µs');

  stopwatch.reset();
  var result = await Isolate.run(() => addUp(numbers));
  print('Isolate: $result in ${stopwatch.elapsedMicroseconds} µs');
}
output
Direct: 60 in 227 µs
Isolate: 60 in 5445 µs

Direct wins: for small work, an isolate costs more than it saves.


Quick Revision

  1. What is the difference between I/O-bound and CPU-bound work?
  2. Why does a long loop make a timer fire late?
  3. Why can't a try catch an error from a future that is not awaited?
  4. When is unawaited(...) the right choice?
  5. What happens if a StreamController is never closed?
  6. How can a race condition happen with only one thread?

Key Takeaways

  • Waiting work uses async/await; calculating work uses an isolate.
  • A long calculation on the main isolate blocks timers, streams, and the UI.
  • Await every future you start, or mark it with unawaited and give it .catchError.
  • A try only catches errors from futures it awaits.
  • Cancel subscriptions you no longer need; close controllers you create.
  • Every await is a gap where other code runs — don't split read-check-write across one.
  • Isolate messages are copied: send little, return summaries.
  • Measure with Stopwatch before optimizing; isolates have a start-up cost.

What's Next?

Lecture 101 — Dart Libraries. This closes Category 16. Our programs already import dart:async and dart:isolate, and they are outgrowing a single file. Next we will see how Dart organizes code into libraries — what import really does and which core libraries ship with Dart.


Video lecture

Subscribe
Placeholder video — the real recording isn't linked yet.