← Back to blog
Dart Internals & Performance · Part 6 of 8
September 10, 202612 min read

Writing Efficient Dart: Profiling and Avoiding Anti-Patterns

DartPerformanceFlutter

Writing Efficient Dart

This is Part 6 of the Dart Internals & Performance series. You now understand the machinery: compilation, GC, constants, FFI. This part turns that knowledge into a practical method for making real apps fast — and, just as important, into the discipline of not optimizing the wrong thing.

The headline rule of all performance work:

Measure first. Optimize the bottleneck. Measure again. Your intuition about what's slow is almost always wrong. The profiler is right.

We'll cover how to profile, how to read what you see, and a tour of the anti-patterns that show up over and over — each tied back to a mechanism from earlier parts.

We're on Dart 3.12 with Flutter DevTools.


Rule zero: profile in the right build

Remember Part 2: debug mode is JIT with asserts on and optimizations off. Timings there are meaningless for real performance. Before you measure anything:

Always profile in --profile (or release) mode. Profile mode is AOT-compiled (real timings) but keeps the DevTools hooks you need. Benchmarking in debug mode is the #1 way people "discover" performance problems that don't exist — and miss the ones that do.

flutter run --profile        # real timings + DevTools timeline/CPU profiler
dart compile exe app.dart && ./app   # for pure-Dart benchmarks, AOT

For pure-Dart microbenchmarks, use package:benchmark_harness (it warms up and averages), not a single Stopwatch reading.


The profiling toolkit (DevTools)

Open DevTools (it launches with flutter run, or dart devtools). The views you'll live in:

| View | Answers the question | Look for | | --- | --- | --- | | Performance (timeline) | "Why are my frames slow?" | Frames over the 16 ms (60 fps) / 8 ms (120 fps) budget; long UI vs Raster bars | | CPU Profiler | "Where is the time going?" | The widest boxes in the flame chart = hottest call paths | | Memory | "Am I leaking / allocating too much?" | A heap line that only climbs; allocation hot spots (Part 3) | | Performance overlay | "Which frames jank, live?" | Red bars in the two graphs (UI thread / raster thread) |

Reading a flame chart

The CPU profiler's flame chart is the single most useful artifact. Mental model:

Width = time. Each box is a function; its width is how much time was spent in it (and its callees). Stacking shows the call hierarchy. The widest boxes are your bottlenecks. Optimize those; ignore the thin ones no matter how "ugly" they look.

main                                          ← full width (everything)
 └─ build()                          ← wide
     ├─ parseHugeJson()      ← VERY wide  ← THIS is the bottleneck
     └─ layoutChildren()  ← narrow         ← ignore for now

This enforces the discipline: you don't guess, you find the widest box and ask "why is that expensive?"


Anti-pattern 1: doing real work in build()

build() can run every frame (Part 3). Anything expensive inside it pays that cost over and over.

// ❌ Sorts a list and formats dates on EVERY rebuild:
@override
Widget build(BuildContext context) {
  final sorted = [...items]..sort((a, b) => a.date.compareTo(b.date)); // O(n log n) per frame
  final formatted = sorted.map((i) => DateFormat.yMMMd().format(i.date)).toList();
  return ListView(children: formatted.map(Text.new).toList());
}
// ✅ Compute once when data changes; cache the result:
late List<String> _formatted;

@override
void didUpdateWidget(MyWidget old) {
  super.didUpdateWidget(old);
  if (old.items != widget.items) _recompute();
}

void _recompute() {
  final sorted = [...widget.items]..sort((a, b) => a.date.compareTo(b.date));
  _formatted = sorted.map((i) => DateFormat.yMMMd().format(i.date)).toList();
}

Rule: build() should describe UI from already-computed state, not compute it. Move sorting, parsing, filtering, and formatting out of the build path.


Anti-pattern 2: rebuilding more than you need

When state changes, rebuild the smallest subtree that depends on it — not the whole screen.

  • Push setState down into small widgets, or use const (Part 4) to fence off static subtrees so they're skipped.
  • With a state manager (e.g. Riverpod — see the Riverpod series), watch the narrowest slice of state a widget needs, so unrelated changes don't rebuild it.
  • Use const constructors everywhere you can — they make subtrees identical() and prune them from the rebuild walk.
// ❌ The whole page rebuilds when only the counter changes.
// ✅ Wrap just the counter in its own widget / Consumer so the rest stays put.

Measure rebuilds with DevTools' "Track widget rebuilds" / the rebuild stats — it highlights widgets rebuilding more than they should.


Anti-pattern 3: eager collections and the lazy-Iterable trap

Dart's Iterable methods (map, where, expand, take) are lazy — they don't run until you iterate. That's a feature and a trap.

// Lazy: nothing happens here. No list is built.
final evens = numbers.where((n) => n.isEven).map((n) => n * n);

// Work happens only now, once, as you iterate:
for (final x in evens) print(x);

Two sides of the same coin:

Good — avoid intermediate lists. Chaining lazily means no throwaway List is allocated between steps (Part 3 GC pressure). Don't sprinkle .toList() between operations.

// ❌ Builds two intermediate lists:
final a = numbers.where((n) => n.isEven).toList();
final b = a.map((n) => n * n).toList();

// ✅ One pass, no intermediates, materialize once at the end:
final b = numbers.where((n) => n.isEven).map((n) => n * n).toList();

Bad — re-iterating recomputes. A lazy Iterable re-runs its pipeline every time you iterate it. Iterate twice, compute twice.

final pipeline = data.map(expensiveTransform); // lazy
final count = pipeline.length;   // runs expensiveTransform for every element...
final first = pipeline.first;    // ...and AGAIN here. Double work.

// ✅ If you'll use it more than once, materialize once:
final results = data.map(expensiveTransform).toList();
results.length; results.first;   // no recomputation

Rule of thumb: keep it lazy while you're still transforming; call .toList()/.toSet() once, at the point you need random access or multiple passes.


Anti-pattern 4: building strings with + in a loop

Strings are immutable. Concatenating with + in a loop allocates a new string every iteration — O(n²) memory traffic.

// ❌ Allocates a new String each iteration:
var s = '';
for (final word in words) {
  s += word + ' ';
}

// ✅ StringBuffer accumulates in one growable buffer:
final buffer = StringBuffer();
for (final word in words) {
  buffer..write(word)..write(' ');
}
final s = buffer.toString();

Same idea generalizes: in hot loops, avoid creating a fresh object per iteration. Reuse buffers, accumulate, and materialize once.


Anti-pattern 5: the wrong data structure / complexity

No micro-optimization beats fixing algorithmic complexity. The classic: membership checks against a List (O(n)) inside a loop (→ O(n²)).

// ❌ O(n²): contains() scans the whole list each time.
final seen = <int>[];
for (final id in ids) {
  if (!seen.contains(id)) seen.add(id);
}

// ✅ O(n): Set membership is O(1) average.
final seen = <int>{};
for (final id in ids) {
  seen.add(id); // Set ignores duplicates
}

Other high-leverage structure choices:

  • Set/Map for membership and lookups, not List.contains/linear search.
  • Typed data (Uint8List, Float64List) for large numeric/byte buffers — no per-element boxing, contiguous memory, far less GC churn than List<int>.
  • Pre-size a list you know the length of: List.filled/List.generate beats repeated add with regrowth.
// Bytes as Uint8List, not List<int>: compact, unboxed, cache-friendly.
final pixels = Uint8List(width * height * 4);

Anti-pattern 6: blocking the isolate with CPU work

Straight from Part 2/Part 3: heavy synchronous CPU work on the UI isolate freezes frames. async/await doesn't help (nothing to wait on) — you need a different isolate.

// ❌ 2s of parsing on the UI isolate = 2s frozen UI:
final data = parseGiantJson(raw);

// ✅ Offload to a background isolate; UI thread stays free:
final data = await Isolate.run(() => parseGiantJson(raw));
// (Flutter's `compute(parseGiantJson, raw)` is the same idea.)

Decision rule: waiting (network, disk) → async/await. Computing (parsing, image processing, crypto) for more than a few milliseconds → isolate. Profile to find which it is.


Anti-pattern 7: forgetting const and over-allocating

We dedicated Part 4 to this, but it belongs on any efficiency checklist:

  • Add const to every widget/object that can be one — zero allocation, fewer rebuilds.
  • Hoist invariant allocations out of loops and build().
  • Don't allocate closures/objects per frame when one cached instance works.
// ❌ New EdgeInsets + TextStyle allocated every build:
Padding(padding: EdgeInsets.all(8), child: Text('Hi', style: TextStyle(fontSize: 14)))

// ✅ Canonicalized once, reused forever:
const Padding(padding: EdgeInsets.all(8), child: Text('Hi', style: TextStyle(fontSize: 14)))

The discipline: don't optimize blind

Two failure modes, equally bad:

  1. Premature optimization — twisting code into knots for a path the profiler shows is 0.1% of runtime. You added bugs and complexity for nothing.
  2. No optimization — shipping obvious O(n²) and per-frame parsing because "Dart is fast enough."

The cure for both is the loop:

Profile → find the widest box → understand why (allocation? complexity? blocking? rebuilds?) → fix that one thing → profile again. Stop when you're under budget. Keep the code you didn't need to change simple.

A quick triage table mapping symptoms to the part that explains the fix:

| Symptom in DevTools | Likely cause | Where it's covered | | --- | --- | --- | | UI thread frames over budget | Work in build(), too many rebuilds | this part, Part 4 | | Heap only climbs | Retained-reference leak | Part 3 | | Periodic GC pauses | Excess short-lived allocation | Part 3, Part 4 | | One long synchronous spike | CPU work blocking the isolate | Part 2, here | | Slow native call spike | Blocking FFI on UI isolate | Part 5 |


Practice Challenges

Challenge 1 — Wrong build. Your colleague measured a screen at 9 ms/frame in flutter run and says it's fine. What's the flaw?

Show solution

They measured in debug mode (JIT, asserts on, unoptimized — Part 2). Those timings aren't representative. Re-measure in profile mode (flutter run --profile) to get real AOT frame times before drawing any conclusion.

Challenge 2 — Fix the loop. Rewrite for efficiency and state the complexity change:

var result = '';
for (final n in numbers) {
  result += '$n,';
}
Show solution
final buf = StringBuffer();
for (final n in numbers) {
  buf..write(n)..write(',');
}
final result = buf.toString();

+= allocates a new string each iteration (≈O(n²) total copying); StringBuffer accumulates in one growable buffer (O(n)).

Challenge 3 — Lazy trap. Why is this slow, and how do you fix it?

final processed = items.map(heavyTransform);
print(processed.length);
print(processed.where((x) => x.valid).length);
Show solution

processed is a lazy Iterable, so heavyTransform runs every time it's iterated — here at least twice (.length, then .where().length). Materialize once:

final processed = items.map(heavyTransform).toList();

Now heavyTransform runs exactly once.

Challenge 4 — Structure swap. This dedupe is O(n²). Make it O(n).

final unique = <String>[];
for (final s in input) {
  if (!unique.contains(s)) unique.add(s);
}
Show solution
final unique = input.toSet().toList(); // or build a Set directly

Set membership is O(1) average, so dedupe is O(n) instead of O(n²) from repeated List.contains.

Challenge 5 — Triage. DevTools shows the heap climbing every time you push and pop a screen, never falling. What category of bug is this and where do you look?

Show solution

A memory leak via retained references (Part 3) — likely an undisposed controller/subscription or a closure capturing the screen's State. Take two heap snapshots, diff them, and follow the retaining path of the leaked State to the reference you forgot to release in dispose().


Questions to test yourself

Q1 (basic). Why must you profile in profile/release mode rather than debug?

Show answer

Debug is JIT with assertions on and optimizations off (Part 2) — timings are unrepresentative. Profile mode is AOT (real timings) but keeps DevTools hooks, so it reflects what users actually experience.

Q2 (basic). In a flame chart, what does the width of a box mean, and which boxes do you optimize?

Show answer

Width = time spent in that function (and its callees). You optimize the widest boxes — the real bottlenecks — and ignore narrow ones regardless of how they look.

Q3 (intermediate). Why is doing sorting/parsing inside build() a problem, and what's the fix?

Show answer

build() can run every frame, so the expensive work repeats each rebuild. Move the computation out of the build path — compute once when the underlying data changes (e.g. in didUpdateWidget/a state update) and cache the result; build() should only describe UI from ready state.

Q4 (intermediate). Explain the double-edged nature of lazy Iterables.

Show answer

Laziness avoids allocating intermediate lists when chaining map/where (good — less GC pressure). But a lazy iterable recomputes its whole pipeline each time it's iterated, so using it multiple times does the work multiple times. Keep it lazy while transforming; .toList() once if you'll iterate more than once or need random access.

Q5 (advanced). When does switching async/await to an isolate actually help, and when does it do nothing?

Show answer

Isolates help with CPU-bound work (parsing, image processing, crypto) that would block the UI isolate's thread — moving it to another isolate frees the UI thread for frames. They do nothing extra for pure I/O waiting (network/disk), which async/await already handles without blocking the thread. Profile to tell which you have.

Q6 (advanced). Why prefer Uint8List over List<int> for a large byte buffer?

Show answer

Uint8List stores bytes in a contiguous, unboxed native buffer — no per-element object boxing, far less memory, better cache locality, and much less GC churn (Part 3). List<int> may box elements and is less compact, which hurts for large numeric/byte data.


Wrapping up

  • Measure first, in profile/release mode — never trust debug timings or your gut.
  • Drive DevTools: Performance timeline for frames, CPU profiler flame chart (width = time) for hot paths, Memory for leaks/allocation.
  • Kill the recurring anti-patterns: work in build(), over-rebuilding, eager/re-iterated Iterables, + string building, wrong data structures (O(n²)), blocking the isolate, and missing const.
  • Tie each symptom to its mechanism: complexity, allocation/GC, blocking, or rebuilds.
  • Run the loop — profile → fix the widest box → profile again — and otherwise keep the code simple.

In Part 7, the finale before the question bank, we tackle the last frontier: metaprogrammingbuild_runner, code generation, and the real story of what happened to Dart macros.