Watch the video (9 min)
On this page25 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. Measure First
  5. 2. One Timing Is Not Enough
  6. 3. A Fair Micro-Benchmark
  7. 4. Choosing the Right Collection
  8. 5. StringBuffer Instead of + in Loops
  9. 6. Lazy Iterable vs Eager toList()
  10. 7. Avoid Repeated Work: Hoisting
  11. 8. Avoid Repeated Work: Caching
  12. 9. const Objects Avoid Allocations
  13. 10. Garbage Collection, Simply Explained
  14. 11. Two Common Leaks
  15. 12. JIT vs AOT: Measure the Difference
  16. 13. A Real-World Example
  17. Code Examples
  18. Common Beginner Mistakes
  19. Best Practices
  20. Mini Challenge
  21. Practice Questions
  22. Practice Answers
  23. Quick Revision
  24. Key Takeaways
  25. What's Next?

Lecture 126 — Performance & Memory

Category21 — Professional Dart Development
Lecture126 of 140
Estimated video length9 min
TargetDart 3.12.2
LevelAdvanced

DevTools shows where a program is slow or growing. This lecture shows what to change — and how to measure that the change helped.

Contents

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

Every output in this lecture is real, produced on Dart 3.12.2 on one laptop. Timing and memory figures are approximate — yours will differ; the comparisons matter.


Prerequisites

Students should already understand, from Lectures 001–125:

  • Set, Map, putIfAbsent, lazy Iterables (043, 047, 050) and records (078)
  • StringBuffer, identical() and const constructors (018, 020, 055)
  • Stopwatch (087) and StreamSubscription.cancel() (092–093)
  • dart compile exe (113) and the DevTools CPU and Memory pages (116)

Connection With Previous Lectures

In Lecture 116 the CPU Profiler named a hot function and the Memory page showed a growing list. Lecture 100 said measure with Stopwatch before optimizing. Two questions remain:

  • How do you measure fairly?
  • Once a function is to blame, what do you change?

Learning Objectives

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

  • Write a fair micro-benchmark with warm-up and repeats
  • Choose a Set over a List for repeated lookups
  • Build long text with StringBuffer instead of +
  • Decide between a lazy Iterable and toList()
  • Avoid repeated work by hoisting and caching
  • Use const objects to avoid allocations
  • Explain garbage collection and recognize two common leaks
  • Compare JIT (dart run) and AOT (dart compile exe) by measuring

1. Measure First

Performance is how fast a program runs; memory is how much space its objects take. Both are easy to guess wrong, so profile to find the slow function (Lecture 116), then measure each fix.

Analogy — a race official uses a stopwatch, not opinion — and lets the runner warm up, then times several laps.


2. One Timing Is Not Enough

Time the same function five times in a row:

dart
int sumMarks(List<int> marks) {
  var total = 0;
  for (var mark in marks) {
    total += mark;
  }
  return total;
}

void main() {
  var marks = List.generate(1000000, (i) => i % 101);
  for (var run = 1; run <= 5; run++) {
    var stopwatch = Stopwatch()..start();
    sumMarks(marks);
    print('Run $run: ${stopwatch.elapsedMicroseconds} µs');
  }
}
output
Run 1: 4296 µs
Run 2: 3466 µs
Run 3: 736 µs
Run 4: 736 µs
Run 5: 736 µs

The first runs are five times slower. dart run uses a JIT compiler (Lecture 001): it starts with quickly-compiled code, then recompiles busy parts into faster machine code. Run 1 measures the warm-up, not your code.


3. A Fair Micro-Benchmark

A micro-benchmark times one small piece of code. Do it fairly: warm up, repeat, average.

dart
void benchmark(String label, void Function() action, {int runs = 20}) {
  for (var i = 0; i < 5; i++) {
    action(); // warm-up: not timed
  }
  var stopwatch = Stopwatch()..start();
  for (var i = 0; i < runs; i++) {
    action();
  }
  var average = stopwatch.elapsedMicroseconds / runs;
  print('$label: ${average.toStringAsFixed(1)} µs per run');
}

// sumMarks from section 2

void main() {
  var marks = List.generate(1000000, (i) => i % 101);
  var total = 0;
  benchmark('sumMarks', () => total = sumMarks(marks));
  print('Total: $total');
}
output
sumMarks: 744.5 µs per run
Total: 49999950

Two runs gave 744.5 and 743.5. Later sections reuse it.


4. Choosing the Right Collection

List.contains checks items one by one; Set.contains jumps straight to the answer (Lecture 047). With 10,000 items the difference is huge:

dart
void main() {
  var idList = List.generate(10000, (i) => 'S$i');
  var idSet = idList.toSet();
  var lookups = List.generate(1000, (i) => 'S${i * 10}');

  var found = 0;
  benchmark(
    'List.contains',
    () => found = lookups.where(idList.contains).length,
  );
  benchmark('Set.contains', () => found = lookups.where(idSet.contains).length);
  print('Found: $found');
}
output
List.contains: 16268.0 µs per run
Set.contains: 47.0 µs per run
Found: 1000

Over 300 times faster. A Map lookup by key is just as fast.


5. StringBuffer Instead of + in Loops

Strings never change, so text += ... copies all the old text into a new string each time. StringBuffer collects the pieces and joins them once:

dart
void main() {
  var text = '';
  benchmark('String +', () {
    text = '';
    for (var i = 0; i < 10000; i++) {
      text += 'Order $i\n';
    }
  });
  benchmark('StringBuffer', () {
    var buffer = StringBuffer();
    for (var i = 0; i < 10000; i++) {
      buffer.write('Order $i\n');
    }
    text = buffer.toString();
  });
  print('Length: ${text.length}');
}
output
String +: 30527.0 µs per run
StringBuffer: 559.5 µs per run
Length: 108890

For a few pieces, + is fine. In a loop, use a buffer.


6. Lazy Iterable vs Eager toList()

where is lazy (Lecture 043): it checks items only when asked. toList() is eager: it checks everything now. Count the checks:

dart
void main() {
  var marks = List.generate(1000000, (i) => i % 101);
  var checks = 0;

  bool isTop(int mark) {
    checks++;
    return mark > 95;
  }

  var firstLazy = marks.where(isTop).first;
  print('Lazy: $firstLazy after $checks checks');

  checks = 0;
  var firstEager = marks.where(isTop).toList().first;
  print('Eager: $firstEager after $checks checks');
}
output
Lazy: 96 after 97 checks
Eager: 96 after 1000000 checks

But laziness repeats the work on every read (Example 10 counts it). Stay lazy to read once or stop early; call toList() to read several times.


7. Avoid Repeated Work: Hoisting

A value that does not change inside a loop should be calculated before it — this is called hoisting:

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

void main() {
  var marks = List.generate(20000, (i) => i % 101);

  var stopwatch = Stopwatch()..start();
  var above = 0;
  for (var mark in marks) {
    if (mark > average(marks)) above++; // recalculated 20000 times
  }
  print('Inside loop: $above in ${stopwatch.elapsedMilliseconds} ms');

  stopwatch.reset();
  above = 0;
  var avg = average(marks); // calculated once
  for (var mark in marks) {
    if (mark > avg) above++;
  }
  print('Hoisted: $above in ${stopwatch.elapsedMilliseconds} ms');
}
output
Inside loop: 10098 in 1509 ms
Hoisted: 10098 in 0 ms

20,000 averages of 20,000 marks is 400 million additions; hoisted, it is one average.


8. Avoid Repeated Work: Caching

When a slow function gets the same input again, remember its answer in a Map. This is caching:

dart
var calls = 0;

int slowShippingCost(String city) {
  calls++;
  var cost = 0;
  for (var i = 0; i < 1000000; i++) {
    cost = (cost + city.codeUnitAt(i % city.length)) % 300;
  }
  return 200 + cost;
}

final shippingCache = <String, int>{};

int shippingCost(String city) =>
    shippingCache.putIfAbsent(city, () => slowShippingCost(city));

void main() {
  var orders = ['Karachi', 'Lahore', 'Karachi', 'Karachi'];
  for (var city in orders) {
    print('$city: ${shippingCost(city)}');
  }
  print('Slow calls: $calls');
}
output
Karachi: 362
Lahore: 486
Karachi: 362
Karachi: 362
Slow calls: 2

Four orders, two slow calls. Cache only unchanging results.


9. const Objects Avoid Allocations

Each Grade('A', 80) allocates (reserves memory for) a new object. A const one is created once and shared (Lecture 055):

dart
class Grade {
  const Grade(this.letter, this.minMark);

  final String letter;
  final int minMark;
}

void main() {
  var grades = <Grade>[];
  benchmark('new Grade', () {
    grades = [for (var i = 0; i < 100000; i++) Grade('A', 80)];
  });
  benchmark('const Grade', () {
    grades = [for (var i = 0; i < 100000; i++) const Grade('A', 80)];
  });
  print(identical(grades.first, grades.last));
}
output
new Grade: 3091.6 µs per run
const Grade: 1161.8 µs per run
true

The const list holds 100,000 references to one object.


10. Garbage Collection, Simply Explained

In Dart you never free memory by hand. The garbage collector (GC) finds objects nothing can reach any more and reuses their memory. ProcessInfo.currentRss (dart:io) reports the program's memory use:

dart
import 'dart:io';

class Order {
  Order(this.total);

  final double total;
}

String megabytes() => '${ProcessInfo.currentRss ~/ (1024 * 1024)} MB';

final kept = <Order>[];

void main() {
  print('Start: ${megabytes()}');

  for (var round = 1; round <= 3; round++) {
    for (var i = 0; i < 1000000; i++) {
      Order(99.5 + i); // garbage straight away
    }
    print('Temporary round $round: ${megabytes()}');
  }

  for (var round = 1; round <= 3; round++) {
    for (var i = 0; i < 1000000; i++) {
      kept.add(Order(99.5 + i)); // still reachable
    }
    print('Kept round $round: ${megabytes()}');
  }
}
output
Start: 168 MB
Temporary round 1: 158 MB
Temporary round 2: 157 MB
Temporary round 3: 159 MB
Kept round 1: 193 MB
Kept round 2: 207 MB
Kept round 3: 294 MB

Temporary orders: memory stays flat. Kept orders: memory climbs, because kept still reaches every one. The GC frees only the unreachable.


11. Two Common Leaks

A memory leak is memory you no longer need but still reach. Two are common:

  • A growing collection — Lecture 116's history list. Give it a maximum size.
  • An uncancelled subscription — a listener keeps its function, and all it uses, alive:
dart
import 'dart:async';

final prices = StreamController<double>.broadcast();

void openPriceScreen(int number) {
  prices.stream.listen((price) => print('Screen $number sees $price'));
}

Future<void> main() async {
  for (var number = 1; number <= 3; number++) {
    openPriceScreen(number); // the screen closes, but its listener stays
  }
  prices.add(99.5);
  await Future.delayed(Duration.zero);
  await prices.close();
}
output
Screen 1 sees 99.5
Screen 2 sees 99.5
Screen 3 sees 99.5

Three closed screens still react and stay in memory. The fix (Lecture 100): return the StreamSubscription and cancel() it when the screen closes — see Example 9 and Question 9.


12. JIT vs AOT: Measure the Difference

Compile a prime counter and time both ways. The shell's time command reports the whole run:

dart
// isPrime: the fast version from Lecture 116, section 9

void main() {
  var stopwatch = Stopwatch()..start();
  var count = 0;
  for (var n = 2; n <= 5000000; n++) {
    if (isPrime(n)) count++;
  }
  print('Primes: $count');
  print('Work took ${stopwatch.elapsedMilliseconds} ms');
}
output
$ dart compile exe primes.dart -o primes
Generated: <folder>/primes
$ time dart run primes.dart
Primes: 348513
Work took 811 ms
dart run primes.dart  0.99s user 0.06s system 80% cpu 1.302 total
$ time ./primes
Primes: 348513
Work took 671 ms
./primes  0.67s user 0.01s system 99% cpu 0.688 total

The work is a little faster AOT (671 vs 811 ms); the big gap is start-up, since dart run compiles first. Compiled, section 10's program printed Start: 13 MB, not 168 MB — no compiler is loaded. Develop with dart run; ship with dart compile exe.


13. A Real-World Example

Sara's shop lists VIP orders, stored as (customer, amount) records — slow with a List and +, fast with a Set and StringBuffer:

dart
String slowReport(List<(String, double)> orders, List<String> vips) {
  var report = '';
  for (var (customer, amount) in orders) {
    if (vips.contains(customer)) report += '$customer: $amount\n';
  }
  return report;
}

String fastReport(List<(String, double)> orders, Set<String> vips) {
  var report = StringBuffer();
  for (var (customer, amount) in orders) {
    if (vips.contains(customer)) report.writeln('$customer: $amount');
  }
  return report.toString();
}

void main() {
  var customers = List.generate(2000, (i) => 'Customer $i');
  var orders = [
    for (var i = 0; i < 20000; i++) (customers[i % 2000], 100.0 + i % 50),
  ];
  var vipList = customers.where((c) => c.endsWith('7')).toList();
  var vipSet = vipList.toSet();

  var slow = '';
  var fast = '';
  benchmark('Slow report', () => slow = slowReport(orders, vipList));
  benchmark('Fast report', () => fast = fastReport(orders, vipSet));
  print('Same result: ${slow == fast}');
}
output
Slow report: 14222.4 µs per run
Fast report: 495.1 µs per run
Same result: true

Same reports, 28 times faster.


Code Examples

Example 1 — Timing One Operation

dart
void main() {
  var marks = List.generate(1000000, (i) => i * 37 % 101);
  var stopwatch = Stopwatch()..start();
  marks.sort();
  stopwatch.stop();
  print('Sorted in ${stopwatch.elapsedMilliseconds} ms');
  print('Lowest ${marks.first}, highest ${marks.last}');
}
output
Sorted in 119 ms
Lowest 0, highest 100

Example 2 — Resetting a Stopwatch

dart
void main() {
  var stopwatch = Stopwatch()..start();
  var squares = [for (var i = 0; i < 1000000; i++) i * i];
  print('Build: ${stopwatch.elapsedMilliseconds} ms');

  stopwatch.reset();
  var total = squares.fold(0, (a, b) => a + b);
  print('Sum $total: ${stopwatch.elapsedMilliseconds} ms');
}
output
Build: 8 ms
Sum 333332833333500000: 4 ms

Example 3 — A Set for Membership

dart
void main() {
  var enrolled = {'Ali', 'Sara', 'Zayan'};
  for (var name in ['Sara', 'Omar', 'Zayan']) {
    print('$name enrolled: ${enrolled.contains(name)}');
  }
}
output
Sara enrolled: true
Omar enrolled: false
Zayan enrolled: true

Example 4 — A Receipt with StringBuffer

dart
void main() {
  var items = {'Notebook': 500, 'Pen': 50, 'Bag': 1500};
  var receipt = StringBuffer('Receipt\n');
  items.forEach((item, price) => receipt.writeln('$item: $price'));
  receipt.write('Items: ${items.length}');
  print(receipt);
}
output
Receipt
Notebook: 500
Pen: 50
Bag: 1500
Items: 3

Example 5 — Lazy take Stops Early

dart
void main() {
  var marks = List.generate(100000, (i) => i * 37 % 101);
  var checked = 0;
  var firstPasses = marks
      .where((m) {
        checked++;
        return m >= 50;
      })
      .take(3);
  print(firstPasses.toList());
  print('Checked $checked of ${marks.length} marks');
}
output
[74, 84, 57]
Checked 8 of 100000 marks

Example 6 — const Lists Are Shared

dart
void main() {
  const a = ['Ali', 'Sara'];
  const b = ['Ali', 'Sara'];
  var c = ['Ali', 'Sara'];
  print(identical(a, b));
  print(identical(a, c));
}
output
true
false

Example 7 — Student Marks: Hoisted Average

dart
void main() {
  var marks = {'Ali': 80, 'Sara': 92, 'Zayan': 67, 'Omar': 74};
  var average = marks.values.reduce((a, b) => a + b) / marks.length; // once
  var above = [
    for (var entry in marks.entries)
      if (entry.value > average) entry.key,
  ];
  print('Average: $average');
  print('Above average: $above');
}
output
Average: 78.25
Above average: [Ali, Sara]

Example 8 — Shopping Total: Cached Prices

dart
var lookups = 0;

int fetchPrice(String product) {
  lookups++; // imagine a slow database query here
  return {'Notebook': 500, 'Pen': 50, 'Bag': 1500}[product]!;
}

final priceCache = <String, int>{};

int price(String product) =>
    priceCache.putIfAbsent(product, () => fetchPrice(product));

void main() {
  var cart = ['Pen', 'Notebook', 'Pen', 'Pen', 'Notebook'];
  var total = 0;
  for (var product in cart) {
    total += price(product);
  }
  print('Total: $total');
  print('Lookups: $lookups');
}
output
Total: 1150
Lookups: 2

Example 9 — Bank Balance: Cancel When Done

dart
import 'dart:async';

Future<void> main() async {
  var balance = StreamController<double>();
  var subscription = balance.stream.listen((b) => print('Balance: $b'));

  balance.add(1000);
  balance.add(1250.5);
  await Future.delayed(Duration.zero);

  await subscription.cancel();
  balance.add(900); // nobody listens any more
  print('Subscription cancelled');
}
output
Balance: 1000.0
Balance: 1250.5
Subscription cancelled

Example 10 — Temperature: Convert Once, Reuse

dart
void main() {
  var celsius = [20.0, 25.0, 30.0];
  var conversions = 0;
  var fahrenheit = celsius.map((c) {
    conversions++;
    return c * 9 / 5 + 32;
  }).toList(); // convert once, then reuse
  print(fahrenheit);
  print('Max: ${fahrenheit.reduce((a, b) => a > b ? a : b)}');
  print('Conversions: $conversions');
}
output
[68.0, 77.0, 86.0]
Max: 86.0
Conversions: 3

Without toList(), printing and reduce each convert again: six conversions.


Common Beginner Mistakes

1. Timing one cold run — section 2's run 1 was five times slower than run 3.

2. Trusting exact numbers — timings change between runs. Compare ratios on one machine.

3. Optimizing without profiling — you speed up code that was never the problem.

4. Reading one lazy Iterable many times — each read repeats the work.

5. Forgetting to cancel — every listen needs a cancel() when you are done.


Best Practices

  • Measure first; warm up, repeat, and label timings approximate.
  • Write const wherever a value is fixed.
  • Bound long-lived collections and cancel subscriptions.
  • Benchmark the way you ship: AOT behaves differently from dart run.

Mini Challenge

Zayan's word game checks the guesses mango, mangoo, peach, plum against the dictionary apple, mango, grape, lemon, peach. Store the dictionary as a Set, build the result with a StringBuffer, give 10 points per valid word, and end with Score: 20.

Try it yourself before opening the solution.

Show solution
dart
void main() {
  var dictionary = {'apple', 'mango', 'grape', 'lemon', 'peach'};
  var guesses = ['mango', 'mangoo', 'peach', 'plum'];
  var result = StringBuffer();
  var score = 0;
  for (var word in guesses) {
    var valid = dictionary.contains(word);
    if (valid) score += 10;
    result.writeln('$word: ${valid ? 'valid' : 'not a word'}');
  }
  result.write('Score: $score');
  print(result);
}
output
mango: valid
mangoo: not a word
peach: valid
plum: not a word
Score: 20

Practice Questions

Beginner

  1. Which class measures elapsed time, and which property gives microseconds?
  2. Why are the first runs of a function under dart run slower?
  3. Put 'spam', 'scam', 'fake' in a Set; print whether it has 'scam' and 'real'.

Application

  1. Build the lines 7 x 1 = 7 to 7 x 3 = 21 with a StringBuffer and print them.
  2. How many checks does [50, 95, 60, 99].where((m) => m > 90).first make? Prove it.
  3. For marks [80, 92, 67], print each as a percentage of the highest, calculating the highest only once.
  4. Are two const Duration(seconds: 1) identical? Two non-const ones?

Challenge

  1. Benchmark List.contains against Set.contains for 50,000 numbers and 500 lookups.
  2. Ali, Sara and Zayan each open and close a score view on a broadcast stream; Zayan reopens his. Make sure a score of 10 reaches only that view.
  3. Time section 12's program both ways. Give two reasons to ship the executable.

Practice Answers

Question 1 — Solution

Stopwatch; elapsedMicroseconds.

Question 2 — Solution

The JIT compiler optimizes busy code only after it has run a few times.

Question 3 — Solution

dart
void main() {
  var banned = {'spam', 'scam', 'fake'};
  print(banned.contains('scam'));
  print(banned.contains('real'));
}
output
true
false

Question 4 — Solution

dart
void main() {
  var table = StringBuffer();
  for (var i = 1; i <= 3; i++) {
    table.writeln('7 x $i = ${7 * i}');
  }
  print(table.toString().trim());
}
output
7 x 1 = 7
7 x 2 = 14
7 x 3 = 21

Question 5 — Solution

dart
void main() {
  var marks = [50, 95, 60, 99];
  var checks = 0;
  var first = marks.where((m) {
    checks++;
    return m > 90;
  }).first;
  print('$first after $checks checks');
}
output
95 after 2 checks

Question 6 — Solution

dart
void main() {
  var marks = [80, 92, 67];
  var highest = marks.reduce((a, b) => a > b ? a : b); // hoisted
  for (var mark in marks) {
    print('$mark is ${(mark / highest * 100).round()}% of the top mark');
  }
}
output
80 is 87% of the top mark
92 is 100% of the top mark
67 is 73% of the top mark

Question 7 — Solution

dart
void main() {
  print(identical(const Duration(seconds: 1), const Duration(seconds: 1)));
  print(identical(Duration(seconds: 1), Duration(seconds: 1)));
}
output
true
false

Question 8 — Solution

With section 3's benchmark function:

dart
void main() {
  var list = List.generate(50000, (i) => i);
  var set = list.toSet();
  var targets = List.generate(500, (i) => i * 100);
  var hits = 0;
  benchmark('List', () => hits = targets.where(list.contains).length);
  benchmark('Set', () => hits = targets.where(set.contains).length);
  print('Hits: $hits');
}
output
List: 9315.0 µs per run
Set: 42.0 µs per run
Hits: 500

Question 9 — Solution

dart
import 'dart:async';

final scores = StreamController<int>.broadcast();

StreamSubscription<int> showScores(String player) =>
    scores.stream.listen((s) => print('$player sees $s'));

Future<void> main() async {
  for (var player in ['Ali', 'Sara', 'Zayan']) {
    var subscription = showScores(player);
    await subscription.cancel();
  }
  var live = showScores('Zayan');
  scores.add(10);
  await Future.delayed(Duration.zero);
  await live.cancel();
  await scores.close();
}
output
Zayan sees 10

Question 10 — Solution

Ours: about 1.3 s with dart run, 0.7 s for ./primes. The executable skips start-up compiling and uses far less memory (and needs no SDK, Lecture 113).


Quick Revision

  1. Why warm up before timing?
  2. Why is += on a string slow in a loop?
  3. What is hoisting? What is caching?
  4. Which objects can the garbage collector free?

Key Takeaways

  • Measure first: warm up, repeat, average; timings are approximate.
  • Set and Map lookups stay fast as data grows; List.contains does not.
  • Build text in loops with StringBuffer.
  • Lazy Iterables stop early but repeat work on each read; toList() to reuse.
  • Hoist unchanging work out of loops; cache repeated slow results; share const objects.
  • The GC frees only unreachable objects; growing collections and uncancelled subscriptions leak.
  • AOT executables start faster and use less memory than dart run.

What's Next?

Lecture 127 — Dart Code Review. We now have habits for clean, organized, fast code. Next we learn to review code — ours and others' — before a change is accepted.


Video lecture

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