Watch the video (10 min)
On this page24 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. Concurrency vs Parallelism
  5. 2. Taking Turns on One Thread
  6. 3. The Event Loop in Detail
  7. 4. Scheduling a Microtask
  8. 5. Scheduling an Event
  9. 6. The Full Ordering Run
  10. 7. Microtasks Created Inside an Event
  11. 8. Microtasks Can Starve Events
  12. 9. Heavy CPU Work Freezes Async Code
  13. 10. Marking It async Does Not Help
  14. 11. Isolates: Dart's Answer
  15. 12. A Real-World Example
  16. Code Examples
  17. Common Beginner Mistakes
  18. Best Practices
  19. Mini Challenge
  20. Practice Questions
  21. Practice Answers
  22. Quick Revision
  23. Key Takeaways
  24. What's Next?

Lecture 97 — Dart Concurrency Model

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

You can start many Futures and Streams at once, but you could not say exactly in which order their callbacks run. This lecture opens the event loop and shows its two queues, so you can predict every line.

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: a line such as at 812 ms shows a different number on every run and every computer.


Prerequisites

Students should already understand, from Lectures 087–096:

  • the event loop and Future.delayed (Lecture 087)
  • Future, Future.value, Future(() => ...), and .then (Lecture 088)
  • async and await (Lecture 089)
  • importing dart:async (Lecture 090) and Future.wait (Lecture 091)
  • Streams and async generators (Lectures 092–096)

Connection With Previous Lectures

Lecture 087 described the event loop as "one thread, one queue", and then admitted that Dart really has two queues. Try predicting this:

dart
void main() {
  Future(() => print('Future'));
  Future.value(1).then((_) => print('then'));
  print('main');
}
output
main
then
Future

then was scheduled second but ran first. With one queue that is impossible. To explain it — and to see why a heavy calculation still freezes async code — we need the real model.


Learning Objectives

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

  • Explain the difference between concurrency and parallelism
  • Describe the event loop step by step
  • Distinguish the microtask queue from the event queue
  • Schedule work with scheduleMicrotask, Future.microtask, Future(), and Timer.run
  • Predict the output of a program that mixes all four
  • Recognize how endless microtasks starve the event queue
  • Prove with Stopwatch that heavy CPU work freezes async code
  • Explain why isolates are Dart's answer to heavy work

1. Concurrency vs Parallelism

Concurrency means several tasks are in progress at the same time. Parallelism means several tasks are running at the same instant.

Analogy — one cook making tea and toast is concurrent: kettle on, bread in, butter ready, pour tea. Only one pair of hands, but nothing waits idle. Two cooks in the kitchen is parallel.

Dart's async code is concurrency with one cook: one thread runs your code, switching between tasks only when one of them waits.


2. Taking Turns on One Thread

Two async functions look like they run together, but each await simply hands the thread back to the event loop:

dart
Future<void> task(String name) async {
  for (var step = 1; step <= 3; step++) {
    print('$name: step $step');
    await Future.delayed(Duration.zero);
  }
}

void main() {
  task('Ali');
  task('Sara');
}
output
Ali: step 1
Sara: step 1
Ali: step 2
Sara: step 2
Ali: step 3
Sara: step 3

Never are two lines running at the same moment. Ali runs until his await, then Sara runs until hers, and so on.


3. The Event Loop in Detail

Dart keeps two queues of waiting work:

  • the microtask queue — small, urgent jobs that must happen before anything else
  • the event queue — everything else: timers, Future(), finished file and network work

The loop follows fixed rules:

  1. Run main() to the end.
  2. Run every microtask, until the microtask queue is empty.
  3. Take one event from the event queue and run it.
  4. Go back to step 2. When both queues are empty, the program exits.

Within each queue, jobs run in the order they were added: first in, first out.


4. Scheduling a Microtask

scheduleMicrotask (from dart:async) adds a function to the microtask queue. Future.microtask does the same, but also returns a Future:

dart
import 'dart:async';

void main() {
  Future(() => print('event'));
  scheduleMicrotask(() => print('microtask 1'));
  Future.microtask(() => print('microtask 2'));
  print('main');
}
output
main
microtask 1
microtask 2
event

The event was added first, but rule 2 empties the microtask queue before rule 3 takes any event.


5. Scheduling an Event

Future(() => ...), Future.delayed(Duration.zero, ...), and Timer.run (also from dart:async) all add a job to the event queue. Among themselves, they keep their order:

dart
import 'dart:async';

void main() {
  Future.delayed(Duration.zero, () => print('Future.delayed zero'));
  Timer.run(() => print('Timer.run'));
  Future(() => print('Future()'));
}
output
Future.delayed zero
Timer.run
Future()

6. The Full Ordering Run

Here is every scheduler in one program:

dart
import 'dart:async';

void main() {
  print('main: start');
  Timer.run(() => print('event: Timer.run'));
  Future(() => print('event: Future()'));
  scheduleMicrotask(() => print('microtask: scheduleMicrotask'));
  Future.microtask(() => print('microtask: Future.microtask'));
  Future.value(1).then((_) => print('microtask: then on Future.value'));
  print('main: end');
}
output
main: start
main: end
microtask: scheduleMicrotask
microtask: Future.microtask
microtask: then on Future.value
event: Timer.run
event: Future()

Line by line:

  1. main: start and main: end — rule 1: synchronous code always finishes first.
  2. scheduleMicrotask — the first job added to the microtask queue.
  3. Future.microtask — the second microtask, so it comes second.
  4. then on Future.value — the future was already complete, so .then put its callback straight into the microtask queue, third in line. This is why then beat Future in the Connection puzzle.
  5. Timer.run — microtasks are empty; rule 3 takes the first event.
  6. Future() — the next event.

7. Microtasks Created Inside an Event

Rule 4 means a microtask scheduled during an event runs before the next event:

dart
import 'dart:async';

void main() {
  Future(() {
    print('Event 1 starts');
    scheduleMicrotask(() => print('Microtask from event 1'));
    print('Event 1 ends');
  });
  Future(() => print('Event 2'));
}
output
Event 1 starts
Event 1 ends
Microtask from event 1
Event 2

The microtask waited for event 1 to finish — nothing interrupts running code — but it jumped ahead of event 2.


8. Microtasks Can Starve Events

Because rule 2 runs microtasks until the queue is empty, a microtask that keeps scheduling another one blocks every event:

dart
import 'dart:async';

void main() {
  final clock = Stopwatch()..start();
  Timer.run(() => print('Timer ran at ${clock.elapsedMilliseconds} ms'));

  var count = 0;
  void next() {
    count++;
    if (clock.elapsedMilliseconds < 300) {
      scheduleMicrotask(next);
    } else {
      print('$count microtasks ran first');
    }
  }

  scheduleMicrotask(next);
}
output
7739249 microtasks ran first
Timer ran at 301 ms

The timer was ready at 0 ms and waited 300 ms. Without the time limit it would wait forever. Keep microtasks short and rare.


9. Heavy CPU Work Freezes Async Code

Async code helps while the program is waiting. Real calculation keeps the single thread busy, so no queue moves. This function counts prime numbers — honest work, not a busy loop:

dart
int countPrimes(int limit) {
  var count = 0;
  for (var n = 2; n <= limit; n++) {
    var isPrime = true;
    for (var d = 2; d * d <= n; d++) {
      if (n % d == 0) {
        isPrime = false;
        break;
      }
    }
    if (isPrime) count++;
  }
  return count;
}

void main() {
  final clock = Stopwatch()..start();
  Future.delayed(Duration(milliseconds: 100), () {
    print('Reminder (due at 100 ms) ran at ${clock.elapsedMilliseconds} ms');
  });
  final primes = countPrimes(5000000);
  print('Found $primes primes at ${clock.elapsedMilliseconds} ms');
}

One run printed (approximate):

output
Found 348513 primes at 812 ms
Reminder (due at 100 ms) ran at 813 ms

The reminder was 700 ms late. In an app, that is a frozen screen.


10. Marking It async Does Not Help

Wrapping the calculation in an async function changes nothing — the body still runs on the same thread. Here a heartbeat should tick every 100 ms (countPrimes is the function from section 9):

dart
Future<int> countPrimesAsync(int limit) async {
  return countPrimes(limit);
}

Future<void> heartbeat(Stopwatch clock) async {
  for (var i = 1; i <= 5; i++) {
    await Future.delayed(Duration(milliseconds: 100));
    print('tick $i at ${clock.elapsedMilliseconds} ms');
  }
}

Future<void> main() async {
  final clock = Stopwatch()..start();
  heartbeat(clock);
  await Future.delayed(Duration(milliseconds: 250));
  print('Heavy work starts at ${clock.elapsedMilliseconds} ms');
  final primes = await countPrimesAsync(5000000);
  print('Heavy work ends at ${clock.elapsedMilliseconds} ms ($primes primes)');
}

One run printed (approximate):

output
tick 1 at 104 ms
tick 2 at 207 ms
Heavy work starts at 253 ms
Heavy work ends at 1065 ms (348513 primes)
tick 3 at 1065 ms
tick 4 at 1167 ms
tick 5 at 1270 ms

Ticks 3 to 5 should have printed at about 300, 400, and 500 ms. The heartbeat simply stopped while the primes were counted.


11. Isolates: Dart's Answer

The fix is a second cook. An isolate is a separate worker with its own memory and its own event loop, running on another thread — real parallelism. The heavy calculation runs there while the main isolate keeps ticking, and the result comes back as a message. Isolates share no variables, so the problems of two threads changing the same value at once cannot happen. We will create isolates in Lecture 098 — Isolates.


12. A Real-World Example

Sara places an order. The app shows the confirmation at once, updates the cart total as a microtask, and books delivery as an event:

dart
import 'dart:async';

void main() {
  final prices = [500, 250, 120];
  print('Order placed by Sara');

  Timer.run(() {
    print('Delivery booked');
    scheduleMicrotask(() => print('Delivery saved to history'));
  });
  Future(() => print('Receipt emailed'));

  scheduleMicrotask(() {
    final total = prices.reduce((a, b) => a + b);
    print('Cart total updated: $total');
  });

  print('Showing confirmation screen');
}
output
Order placed by Sara
Showing confirmation screen
Cart total updated: 870
Delivery booked
Delivery saved to history
Receipt emailed

Synchronous lines first, then the microtask, then the first event — whose own microtask runs before the second event.


Code Examples

Example 1 — Microtask Beats Event

dart
import 'dart:async';

void main() {
  Future(() => print('Event'));
  scheduleMicrotask(() => print('Microtask'));
  print('Sync');
}
output
Sync
Microtask
Event

Example 2 — Future.microtask vs Timer.run

dart
import 'dart:async';

void main() {
  Timer.run(() => print('Timer.run'));
  Future.microtask(() => print('Future.microtask'));
}
output
Future.microtask
Timer.run

Example 3 — First In, First Out

dart
import 'dart:async';

void main() {
  scheduleMicrotask(() => print('1'));
  scheduleMicrotask(() => print('2'));
  scheduleMicrotask(() => print('3'));
}
output
1
2
3

Example 4 — then on a Completed Future

dart
void main() {
  Future(() => print('Future()'));
  Future.value('ready').then(print);
}
output
ready
Future()

Example 5 — Two Tasks Taking Turns

dart
Future<void> worker(String name) async {
  for (var job = 1; job <= 2; job++) {
    print('$name does job $job');
    await Future.delayed(Duration.zero);
  }
}

void main() {
  worker('Zayan');
  worker('Sara');
}
output
Zayan does job 1
Sara does job 1
Zayan does job 2
Sara does job 2

Example 6 — A Microtask From Inside an Event

dart
import 'dart:async';

void main() {
  Future(() {
    print('A');
    scheduleMicrotask(() => print('B'));
  });
  Future(() => print('C'));
}
output
A
B
C

Example 7 — Student Marks

dart
import 'dart:async';

void main() {
  final marks = [80, 75, 85];
  print('Marks saved for Ali');
  Future(() => print('Report emailed'));
  scheduleMicrotask(() {
    final total = marks.reduce((a, b) => a + b);
    print('Total updated: $total');
  });
  print('Showing marks screen');
}
output
Marks saved for Ali
Showing marks screen
Total updated: 240
Report emailed

Example 8 — A Sales Report That Freezes the Spinner

dart
void main() {
  final clock = Stopwatch()..start();
  Future.delayed(Duration(milliseconds: 50), () {
    print('Spinner update at ${clock.elapsedMilliseconds} ms');
  });
  var total = 0;
  for (var i = 0; i < 300000000; i++) {
    total += i % 10;
  }
  print('Total $total at ${clock.elapsedMilliseconds} ms');
}

One run printed (approximate):

output
Total 1350000000 at 339 ms
Spinner update at 341 ms

How it works

  1. The spinner update was due at 50 ms.
  2. The loop adds 300 million small prices without ever giving the thread back.
  3. Only when main ends can the event loop run the overdue spinner update.

Example 9 — Bank Balance With Two Deposits

dart
var balance = 1000;

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

Future<void> main() async {
  await Future.wait([deposit('Ali', 200), deposit('Sara', 300)]);
  print('Final balance: $balance');
}
output
Ali deposited 200, balance 1200
Sara deposited 300, balance 1500
Final balance: 1500

How it works

  1. Both deposits are in progress at the same time — concurrency.
  2. Each balance += amount line runs to the end before anything else can run, because there is only one thread.
  3. No deposit is lost, and the final balance is exactly 1500.

Example 10 — Temperature Readings

dart
import 'dart:async';

void main() {
  final readings = [21.5, 22.0, 22.8];
  for (final reading in readings) {
    Timer.run(() {
      print('Reading: $reading °C');
      scheduleMicrotask(() => print('  saved $reading'));
    });
  }
  print('Sensor started');
}
output
Sensor started
Reading: 21.5 °C
  saved 21.5
Reading: 22.0 °C
  saved 22.0
Reading: 22.8 °C
  saved 22.8

How it works

  1. The loop puts three events in the queue; Sensor started prints first.
  2. After each event, its microtask runs before the next event starts.

Common Beginner Mistakes

1. Forgetting the dart:async import

dart
void main() {
  // scheduleMicrotask(() => print('Hi')); // ✗
  // Timer.run(() => print('Hi')); // ✗
}
output
Error: Method not found: 'scheduleMicrotask'.
Error: Undefined name 'Timer'.

Add import 'dart:async'; at the top. Future needs no import; these two do.

2. Thinking Future() runs "as soon as possible"

Future(() => ...) goes to the event queue, behind every microtask and every event already waiting. Use Future.microtask when the job must run first.

3. Thinking async means "on another thread"

An async function runs on the same thread. A heavy calculation inside it still freezes everything (section 10).

4. Endless microtasks

A microtask that always schedules another one starves timers, input, and I/O forever (section 8).

5. Relying on exact milliseconds

Timings vary between runs and machines. Reason about order, not about precise numbers.


Best Practices

  • Use plain Future, async, and await in everyday code; reach for scheduleMicrotask only when something truly must run before the next event.
  • Keep every microtask short, and never let microtasks schedule each other without a limit.
  • Keep synchronous chunks of work small so the event loop stays responsive.
  • Measure suspected slow code with Stopwatch before deciding it needs an isolate.
  • Move heavy CPU work off the main isolate (Lecture 098).

Mini Challenge

Using exactly one print, one scheduleMicrotask, one Future(), and one Timer.run — and no delays — make Zayan's shop print:

output
Order placed
Payment checked
Receipt printed
Delivery booked

Try it yourself before opening the solution.

Show solution
dart
import 'dart:async';

void main() {
  Future(() => print('Receipt printed'));
  Timer.run(() => print('Delivery booked'));
  scheduleMicrotask(() => print('Payment checked'));
  print('Order placed');
}
output
Order placed
Payment checked
Receipt printed
Delivery booked

The microtask beats both events, and Timer.run and Future() are both events, so the one scheduled first runs first: Future() must come before Timer.run.


Practice Questions

Work through these in order — they run from beginner to application to challenge.

Beginner

  1. Write a program that schedules a microtask printing Hello from a microtask.
  2. Predict the output: print('A');, then Future(() => print('B'));, then scheduleMicrotask(() => print('C'));.
  3. In one sentence each, explain concurrency and parallelism.

Application

  1. Predict the output, then run it:

    dart
    import 'dart:async';
    
    void main() {
      Timer.run(() => print('X'));
      Future.microtask(() => print('Y'));
      Future.value(0).then((_) => print('Z'));
      print('W');
    }
  2. Predict the output, then run it:

    dart
    import 'dart:async';
    
    void main() {
      Future(() {
        print('1');
        Future(() => print('4'));
        scheduleMicrotask(() => print('2'));
      });
      Future(() => print('3'));
    }
  3. Write two async functions for Ali and Sara that each print two steps, with await Future.delayed(Duration.zero) between, so the output alternates.

  4. Change only the second line so that Cache updated prints before Screen redrawn:

    dart
    import 'dart:async';
    
    void main() {
      Timer.run(() => print('Screen redrawn'));
      Future(() => print('Cache updated'));
    }
  5. Use Stopwatch to time countPrimes(1000000) (section 9) and print the count and time.

Challenge

  1. Schedule a reminder with Future.delayed for 100 ms, then call countPrimes(3000000). Print when the reminder actually ran, and explain the number.

  2. Predict the output, then run it:

    dart
    import 'dart:async';
    
    void main() {
      print('start');
      Future(() {
        print('event A');
        Future.microtask(() => print('microtask from A'));
      });
      scheduleMicrotask(() {
        print('microtask M');
        Timer.run(() => print('timer from M'));
      });
      print('end');
    }

Practice Answers

Question 1 — Solution

dart
import 'dart:async';

void main() {
  scheduleMicrotask(() => print('Hello from a microtask'));
}
output
Hello from a microtask

Question 2 — Solution

output
A
C
B

A is synchronous; the microtask C runs before the event B.

Question 3 — Solution

Concurrency: several tasks are in progress at the same time, taking turns. Parallelism: several tasks run at the same instant on different threads.

Question 4 — Solution

output
W
Y
Z
X

W is synchronous. Y and Z are microtasks in scheduling order. X is an event.

Question 5 — Solution

output
1
2
3
4

2 is a microtask created during event 1. 3 was queued before 4.

Question 6 — Solution

dart
Future<void> ali() async {
  print('Ali: step 1');
  await Future.delayed(Duration.zero);
  print('Ali: step 2');
}

Future<void> sara() async {
  print('Sara: step 1');
  await Future.delayed(Duration.zero);
  print('Sara: step 2');
}

void main() {
  ali();
  sara();
}
output
Ali: step 1
Sara: step 1
Ali: step 2
Sara: step 2

Question 7 — Solution

Replace Future( with Future.microtask(, so the job goes to the microtask queue:

dart
import 'dart:async';

void main() {
  Timer.run(() => print('Screen redrawn'));
  Future.microtask(() => print('Cache updated'));
}
output
Cache updated
Screen redrawn

Question 8 — Solution

dart
int countPrimes(int limit) {
  var count = 0;
  for (var n = 2; n <= limit; n++) {
    var isPrime = true;
    for (var d = 2; d * d <= n; d++) {
      if (n % d == 0) {
        isPrime = false;
        break;
      }
    }
    if (isPrime) count++;
  }
  return count;
}

void main() {
  final clock = Stopwatch()..start();
  final primes = countPrimes(1000000);
  print('$primes primes in ${clock.elapsedMilliseconds} ms');
}

One run printed (approximate):

output
78498 primes in 87 ms

Question 9 — Solution

Using countPrimes from Question 8:

dart
void main() {
  final clock = Stopwatch()..start();
  Future.delayed(Duration(milliseconds: 100), () {
    print('Reminder ran at ${clock.elapsedMilliseconds} ms');
  });
  print('${countPrimes(3000000)} primes');
}

One run printed (approximate):

output
216816 primes
Reminder ran at 402 ms

The reminder was ready at 100 ms, but the event loop takes a new event only after the running code — the whole calculation and the rest of main — has finished.

Question 10 — Solution

output
start
end
microtask M
event A
microtask from A
timer from M

microtask M runs before any event. It adds timer from M to the end of the event queue, behind event A. event A creates a microtask, which runs before the next event.


Quick Revision

  1. What is the difference between concurrency and parallelism?
  2. How many threads run your Dart code in one isolate?
  3. Name the two queues and say which one runs first.
  4. Which two calls add to the microtask queue? Which three add to the event queue?
  5. Why does .then on Future.value run before Future()?
  6. What happens to timers if microtasks keep scheduling more microtasks?
  7. Why does marking a heavy function async not stop it from freezing the program?

Key Takeaways

  • Concurrency is tasks taking turns; parallelism is tasks running at the same instant.
  • Dart's async code is concurrent on one thread; await hands the thread back to the event loop.
  • The loop: run main, empty the microtask queue, run one event, repeat.
  • scheduleMicrotask, Future.microtask, and .then on a completed future go to the microtask queue.
  • Future(), Future.delayed, and Timer.run go to the event queue; each queue is first in, first out.
  • Running code is never interrupted; endless microtasks starve every event.
  • Heavy CPU work freezes timers and async code, async or not.
  • Isolates add real parallelism with separate memory.

What's Next?

Lecture 098 — Isolates. We proved that counting primes stops every timer and heartbeat in the program. Next we will start a second isolate, run that calculation on it, and keep the main isolate responsive while the work happens in parallel.


Video lecture

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