On this page23 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. Two Kinds of Slow
- 2. Don't Block the Event Loop
- 3. Move CPU Work to an Isolate
- 4. Await What You Start
- 5. Fire-and-Forget on Purpose
- 6. Handle Every Async Error
- 7. Cancel Subscriptions, Close Controllers
- 8. Race Conditions on Shared State
- 9. Keep Isolate Messages Small
- 10. Measure Before Optimizing
- 11. A Real-World Example
- Code Examples
- Common Beginner Mistakes
- Best Practices
- Mini Challenge
- Practice Questions
- Practice Answers
- Quick Revision
- Key Takeaways
- What's Next?
Lecture 100 — Concurrency Best Practices
| Category | 16 — Concurrency & Isolates |
| Lecture | 100 of 140 |
| Estimated video length | 10 min |
| Target | Dart 3.12.2 |
| Level | Intermediate |
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 msas "about one and a half seconds"; your numbers will differ.
Prerequisites
Students should already understand, from Lectures 087–099:
- the event loop,
async/await, andtry/catcharoundawait(087–090) Future.waitandStopwatch(091)listen,StreamSubscription.cancel(), andStreamController(092–093)Isolate.runand 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/awaitand an isolate for a given task - Recognize code that blocks the event loop
- Await every future you start, or mark it with
unawaitedon 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
Stopwatchbefore 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:
| Kind | What it does | Examples | Tool |
|---|---|---|---|
| I/O-bound | mostly waits | network, files, timers | async/await |
| CPU-bound | mostly calculates | big loops, parsing huge data | an 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:
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');
}Sum: 2000000001000000000
Loop done at 1492 ms
Timer fired at 1493 msThe 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:
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');
}Timer fired at 103 ms
Sum: 2000000001000000000
Loop done at 1502 msSame 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:
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');
}All work finished
Saved marks for AliThe 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:
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');
}Page shown
Logged visit by SaraThe 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:
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');
}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:
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');
}Temperature: 20.0
Temperature: 21.0
Temperature: 22.0
Stopped listeningWithout cancel() this stream would tick forever. Likewise, whoever creates a
StreamController must close() it, or listeners never hear "done":
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();
}Ali joined
Sara joined
Chat closedDelete 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:
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');
}Ali took 700, balance 300
Sara took 600, balance 400
Final balance: 4001300 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):
Future<void> main() async {
await withdraw('Ali', 700);
await withdraw('Sara', 600);
print('Final balance: $balance');
}Ali took 700, balance 300
Sara refused
Final balance: 300A 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:
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');
}Sent 1,000,000 maps: 1000000 in 676 ms
Sent one int: 1000000 in 0 msSend 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:
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');
}Direct: 80.6 in 371 µs
Isolate: 80.6 in 5374 µsThe 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:
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');
}Jan: spent 25000.0, balance 35000.0
Feb: spent 25000.0, balance 10000.0
Mar skipped: Exception: Server busy
Done in 942 msEvery future is awaited, every error caught, and balance changes in strict order.
Code Examples
Example 1 — Await the Save
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');
}Saved marks for Ali
All work finishedExample 2 — Start Early, Catch at the Await
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');
}
}Loading...
Caught: Exception: Server downStarting early is fine if the future is awaited inside a try later.
Example 3 — Deliberate unawaited
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');
}Screen ready
Ignored: Exception: OfflineExample 4 — Cancel After a Few Values
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');
}Tick 1
Tick 2
Tick 3
CancelledExample 5 — Close So await for Ends
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');
}Mark: 80
Mark: 95
All marks readclose() is not awaited: its future completes only after the loop below reads everything.
Example 6 — A Heavy Loop in an Isolate
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');
}Even numbers: 250000000Example 7 — Student Marks
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');
}Average of 300000 students: 79.99981Only the marks cross into the isolate — not the names — and one double comes back.
Example 8 — Shopping Total
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');
}
}Total: 500.0
Order problem: Exception: Lamp out of stockPrices are I/O, so they load with await, in parallel, inside the try.
Example 9 — Bank Balance in Order
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');
}Zayan: +200 → 700
Ali: -300 → 400
Sara: +150 → 550
Final balance: 550Each change finishes before the next starts, so none is lost.
Example 10 — Temperature Sensor with an Error
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');
}Temperature: 24.5
Sensor error: Exception: Wire loose
Temperature: 25.0
Sensor offCommon 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_futureslint. - Pair every
listenwith acancel, and everyStreamControllerwith aclose. - Sequence operations that touch the same data.
- Send small messages to isolates and return summaries.
- Measure with
Stopwatchbefore 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
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');
}Zayan → 100
Sara → 100
Buggy score: 100
Zayan → 100
Sara → 200
Fixed score: 200In parallel, both read 0 before either writes. Sequenced, the second read sees 100.
Practice Questions
Solve each one before looking at the answers.
Beginner
-
For each task, say I/O-bound or CPU-bound: downloading a file, sorting ten million numbers, waiting 2 seconds, resizing a large photo.
-
What does this print, and in what order?
Future<void> greet() async { await Future.delayed(Duration(milliseconds: 100)); print('Hello'); } Future<void> main() async { greet(); print('Bye'); } -
Fix Question 2 so
Helloprints beforeBye.
Application
fetchBalance()throwsException('Timeout')after 200 ms. Start it, printWaiting..., then await it in atryand printFailed: Exception: Timeout.- Listen to
Stream.periodic(Duration(milliseconds: 250), (i) => i)and cancel after about 600 ms. Print each value and thenDone. - Create a
StreamController<String>, listen withonDone: () => print('Finished'), add'Ali', and close it. - Use
Isolate.runto count the numbers below 100,000,000 that are divisible by 3.
Challenge
- Two deposits of 50 into
balance = 100each read, wait 100 ms, then write. Show the lost update, then fix it by changing onlymain. - Rewrite
depositfrom Question 8 so it works correctly even withFuture.wait. - Write
addUp(List<int> numbers)and time it on[10, 20, 30]directly and withIsolate.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
Bye
Hellogreet() is not awaited, so Bye comes first.
Question 3 — Solution
Future<void> greet() async {
await Future.delayed(Duration(milliseconds: 100));
print('Hello');
}
Future<void> main() async {
await greet();
print('Bye');
}Hello
ByeQuestion 4 — Solution
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');
}
}Waiting...
Failed: Exception: TimeoutQuestion 5 — Solution
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');
}0
1
DoneQuestion 6 — Solution
import 'dart:async';
Future<void> main() async {
var controller = StreamController<String>();
controller.stream.listen(print, onDone: () => print('Finished'));
controller.add('Ali');
await controller.close();
}Ali
FinishedQuestion 7 — Solution
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');
}Divisible by 3: 333333340 counts too, which is why the answer ends in 4.
Question 8 — Solution
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');
}Buggy: 150
Fixed: 200Question 9 — Solution
Wait first, then read and write with no await in between:
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');
}Balance: 200Question 10 — Solution
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');
}Direct: 60 in 227 µs
Isolate: 60 in 5445 µsDirect wins: for small work, an isolate costs more than it saves.
Quick Revision
- What is the difference between I/O-bound and CPU-bound work?
- Why does a long loop make a timer fire late?
- Why can't a
trycatch an error from a future that is not awaited? - When is
unawaited(...)the right choice? - What happens if a
StreamControlleris never closed? - 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
unawaitedand give it.catchError. - A
tryonly catches errors from futures it awaits. - Cancel subscriptions you no longer need; close controllers you create.
- Every
awaitis a gap where other code runs — don't split read-check-write across one. - Isolate messages are copied: send little, return summaries.
- Measure with
Stopwatchbefore 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.