08 · Performance & Profiling¶
Every prior module cared about correctness; this one is about speed and
measuring it honestly. Dart's rule of thumb is the same as everywhere else:
measure before optimizing, because intuition about what's slow is wrong
often enough to be dangerous. This module covers Stopwatch,
dart:developer's Timeline, and one of the most common Dart-specific
performance traps.
Measuring with Stopwatch¶
Stopwatch is the simplest tool: start it, run code, read the elapsed time.
void main() {
final sw = Stopwatch()..start();
var total = 0;
for (int i = 0; i < 10000000; i++) {
total += i;
}
sw.stop();
print('Sum: $total, took ${sw.elapsedMilliseconds}ms');
}
// Sum: 49999995000000, took 12ms
A single run is noisy — background work, JIT warm-up, and GC pauses all
shift the number. For anything you're making a real decision from, run it
several times (or use the benchmark_harness package, which handles
warm-up iterations for you) rather than trusting one number.
The trap: String += in a loop is quadratic¶
String in Dart is immutable — every += doesn't mutate the existing
string, it allocates an entirely new one and copies the old contents
into it. In a loop, that turns what looks like linear work into quadratic
work.
String concatWithPlus(int n) {
String s = '';
for (int i = 0; i < n; i++) {
s += 'x'; // each += allocates a whole new string, O(n) per iteration
}
return s;
}
String concatWithBuffer(int n) {
final buffer = StringBuffer();
for (int i = 0; i < n; i++) {
buffer.write('x'); // appends into a growable internal buffer
}
return buffer.toString();
}
void main() {
const n = 200000;
final sw1 = Stopwatch()..start();
concatWithPlus(n);
sw1.stop();
print('String += : ${sw1.elapsedMilliseconds}ms');
final sw2 = Stopwatch()..start();
concatWithBuffer(n);
sw2.stop();
print('StringBuffer: ${sw2.elapsedMilliseconds}ms');
}
// String += : 1029ms
// StringBuffer: 2ms
That's a ~500x difference for 200,000 characters, and the gap only widens
as n grows, because += is O(n) per call while StringBuffer.write is
amortized O(1) per call. Any loop building up a string — logging, report
generation, HTML/JSON assembly by hand — should reach for StringBuffer
(or List<String>.join()) instead of repeated +=.
Tagging work with dart:developer's Timeline¶
For anything more structured than "how long did this whole function take,"
Timeline.startSync/finishSync mark named spans that show up in DevTools'
timeline view (and in Flutter's performance overlay) — useful once you're
profiling a real app rather than a standalone benchmark.
import 'dart:developer' as dev;
int fib(int n) => n < 2 ? n : fib(n - 1) + fib(n - 2);
void main() {
dev.Timeline.startSync('fib-30');
final result = fib(30);
dev.Timeline.finishSync();
print('fib(30) = $result');
}
// fib(30) = 832040
Running this under dart run --observe and opening DevTools' CPU profiler
would show a fib-30 span with fib's recursive calls nested inside it —
the same API Flutter's own framework code uses to label frame-building work
in the DevTools timeline.
Other common Dart performance traps¶
- Rebuilding collections instead of reusing them —
list.map(...).toList()inside a hot loop allocates a new list every call; hoist it out if the input doesn't change per-iteration. List.containson a large list — O(n) per check; switch to aSet(O(1) average) if you're doing membership checks repeatedly.- Synchronous heavy work on the main isolate — see isolates: a slow loop blocks everything else, including a Flutter UI's frame rendering, not just the calling function.
Cheat sheet¶
| Tool/trap | What it's for |
|---|---|
Stopwatch()..start() / .elapsedMilliseconds |
Quick, ad-hoc timing |
| Multiple runs, not one | A single measurement is noisy (JIT warm-up, GC) |
String += in a loop |
Quadratic — allocates and copies on every iteration |
StringBuffer / List<String>.join() |
Linear — the fix for repeated string building |
dart:developer Timeline.startSync/finishSync |
Named spans visible in DevTools' profiler |
Set vs List for membership checks |
O(1) average vs O(n) per .contains() call |
| CPU-bound work on the main isolate | Blocks everything else sharing that isolate |
How It Actually Works¶
String += in a loop is quadratic because Dart Strings are immutable —
there is no in-place append. Every s += chunk allocates an entirely new
string object sized to hold the combined contents and copies both the old
s and chunk's bytes into it; the old s becomes garbage. Across n
iterations building a string that ends up length L, the total bytes
copied across all those reallocations sums to O(L²)/O(n²)-like growth, not
O(n) — which is exactly why StringBuffer exists: it maintains an internal
growable buffer (much like List's doubling-array strategy) and only
materializes the final immutable String once, via .toString(), turning
the whole operation back into amortized linear time.
dart:developer's Timeline API doesn't do timing itself in Dart code —
Timeline.startSync/finishSync (and TimelineTask) emit structured
events into the VM's own low-overhead tracing buffer, the same
infrastructure DevTools' timeline view reads from. This is a genuinely
different mechanism from a Stopwatch: a Stopwatch measures wall-clock
time from within your Dart code with no OS/VM visibility into what else was
happening concurrently (GC pauses, other isolates, JIT compilation), while
Timeline events are visible alongside VM-level events like garbage
collection pauses in the profiler, letting you see whether a slow span
was your code or the VM pausing to collect garbage.
The reason a GC pause can dominate a profile in the first place: Dart uses
a generational garbage collector — most allocations (which in idiomatic
Dart code, given String/closure/collection immutability patterns, is a
lot of allocations) go into a young generation collected frequently but
cheaply via a copying/scavenging pass; only objects that survive several
young-generation collections get promoted to an older generation collected
less often but more expensively. Code that allocates heavily in tight loops
(exactly what the quadratic String += pattern does) drives young-gen
collection frequency up, which is one of the most common real sources of
GC-attributable slowdowns profiling reveals in Dart programs.
Exercise¶
Write two functions that both build a List<int> of the squares of
0..n-1: one using list.add(i * i) inside a plain for loop after
pre-sizing with List.filled(n, 0, growable: false) and indexed
assignment, and one using repeated list.add(...) on a List<int> grown
from empty. Time both for n = 5000000 with Stopwatch and print the
result. Then wrap the growable version in Timeline.startSync/
finishSync under a named span, and note in a comment what you'd expect to
see if you opened this in DevTools' timeline view.