---
title: 'Performance Tuning'
source: 'https://academia.sh/en/courses/flutter/performance-tuning'
course: Flutter
language: en
updated: '2026-10-21T05:02:53+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Explains the logic of reducing unnecessary rebuilds by building the unchanging subtree with a const constructor'
    - 'States with which constraint a relayout boundary is formed'
    - 'Counts, from the measured record, how many nodes change width when the request of a widget changes'
    - 'Separates a performance problem from a budget overrun and from clamped sizing'
---

# Performance Tuning

Unnecessary rebuilds and relayouts are the most common source of performance problems. The lesson shows with a measured record how high a change climbs in the tree.

In the previous lesson the inspector showed a problem. Most of the time the problem is not a wrong width but unnecessary work: the unchanging subtree of a widget is rebuilt or measured again on every change. This lesson looks at two questions. How high does the change of one widget climb in the tree? How much work is being done in the tree?

Performance itself is a number, and this lesson writes no number; it measures no time. What is written is how many nodes a change touches. Time and the frame budget are the subject of other work.

## The Relayout Boundary

The first question is how high the change climbs in the tree. If the request of a widget changes and its parent reads the size of the child, the parent must be laid out again as well; if the parent of that parent also reads the size of its child, the change continues upward. According to the documentation of the framework, if the parent does not read the size of the child, or if the constraint given to the child is tight, the change stops there: the child can have only one size, and the parent knows it. This point is called a relayout boundary.

The measurable claim of this lesson is this: a widget that gives a tight constraint stops the change beneath it from climbing. In the model, this claim is tested by counting which nodes change width when the request of a widget changes.

## The Measured Layer

Every lesson of the course uses the same plain Dart model. The model gives a widget tree the screen width and records who determines each node's width with four names. The file below produces no output; the measured blocks import it.

```dart
// sizing.dart — classifies who decides a widget's width
const sizings = ['forced', 'chosen', 'clamped', 'stretched'];

class Limit {
  final int min;
  final int? max; // null: unbounded
  const Limit(this.min, this.max);

  bool get tight => max == min;
  Limit loosen() => Limit(0, max);
  Limit deflate(int by) => Limit(_floor(min - by), max == null ? null : _floor(max! - by));
  String bounds() => '$min..${max ?? 'inf'}';
}

int _floor(int value) => value < 0 ? 0 : value;

class Part {
  final String kind;
  final String name;
  final int? width; // null: as wide as it can get
  final List<Part> children;
  final int factor;
  final bool tightFlex;
  const Part(this.kind, this.name, this.width, this.children,
      {this.factor = 0, this.tightFlex = true});
}

Part leaf(String name, int width) => Part('leaf', name, width, const []);
Part fill(String name) => Part('leaf', name, null, const []);
Part pad(String name, int padding, Part child) => Part('pad', name, padding, [child]);
Part center(String name, Part child) => Part('center', name, null, [child]);
Part fixed(String name, int width, Part child) => Part('fixed', name, width, [child]);
Part column(String name, List<Part> children) => Part('column', name, null, children);
Part row(String name, List<Part> children) => Part('row', name, null, children);
Part flex(String name, int factor, Part child, {bool tight = true}) =>
    Part('flex', name, null, [child], factor: factor, tightFlex: tight);

class Sizing {
  final String sizing;
  final String path;
  final int width;
  final Limit limit;
  const Sizing(this.sizing, this.path, this.width, this.limit);
}

Sizing classify(String path, int? desire, Limit limit) {
  if (limit.tight) return Sizing('forced', path, limit.min, limit);
  if (desire == null) {
    return limit.max == null
        ? Sizing('clamped', path, limit.min, limit)
        : Sizing('stretched', path, limit.max!, limit);
  }
  if (desire < limit.min) return Sizing('clamped', path, limit.min, limit);
  if (limit.max != null && desire > limit.max!) {
    return Sizing('clamped', path, limit.max!, limit);
  }
  return Sizing('chosen', path, desire, limit);
}

int layout(Part part, Limit limit, List<Sizing> log, [String path = '']) {
  final here = '$path/${part.name}';
  final below = <Sizing>[];
  if (part.kind == 'fixed') {
    final own = classify(here, part.width, limit);
    layout(part.children.first, Limit(own.width, own.width), below, here);
    log..add(own)..addAll(below);
    return own.width;
  }
  int? desire;
  switch (part.kind) {
    case 'leaf':
      desire = part.width;
    case 'pad':
      final space = 2 * part.width!;
      desire = layout(part.children.first, limit.deflate(space), below, here) + space;
    case 'center':
      final inner = layout(part.children.first, limit.loosen(), below, here);
      desire = limit.max == null ? inner : null;
    case 'flex':
      desire = layout(part.children.first, limit, below, here);
    case 'column':
      var widest = 0;
      for (final child in part.children) {
        final width = layout(child, limit.loosen(), below, here);
        if (width > widest) widest = width;
      }
      desire = widest;
    case 'row':
      final logs = [for (final _ in part.children) <Sizing>[]];
      final bounded = limit.max != null;
      var used = 0;
      var factors = 0;
      for (var i = 0; i < part.children.length; i++) {
        final child = part.children[i];
        if (child.kind == 'flex' && bounded) {
          factors += child.factor;
        } else {
          used += layout(child, const Limit(0, null), logs[i], here);
        }
      }
      final left = bounded ? _floor(limit.max! - used) : 0;
      var given = 0;
      var seen = 0;
      for (var i = 0; i < part.children.length; i++) {
        final child = part.children[i];
        if (child.kind != 'flex' || !bounded) continue;
        seen += child.factor;
        final share = (seen == factors ? left : left * seen ~/ factors) - given;
        given += share;
        final room = child.tightFlex ? Limit(share, share) : Limit(0, share);
        used += layout(child, room, logs[i], here);
      }
      for (final entries in logs) {
        below.addAll(entries);
      }
      desire = bounded ? null : used;
  }
  final own = classify(here, desire, limit);
  log..add(own)..addAll(below);
  return own.width;
}

List<Sizing> measure(Part root, int screen) {
  final log = <Sizing>[];
  layout(root, Limit(screen, screen), log);
  return log;
}

void printSizes(List<Sizing> log) {
  for (final entry in log) {
    print('${entry.sizing}: ${entry.path} width=${entry.width} constraint=${entry.limit.bounds()}');
  }
}

void countSizings(List<Sizing> log) {
  for (final name in sizings) {
    print('$name: ${log.where((entry) => entry.sizing == name).length}');
  }
  print('parent: ${log.where((entry) => entry.sizing != 'chosen').length}');
}
```

In this lesson the model builds this sizing's rule; the model is not the framework itself. In the model, a change is calling the same tree function again with another request and comparing the two records. The model does not carry the rebuild work or the time of the framework.

## The Counted Screen

Two trees carry the same counter, that is, the leaf whose request will change. In the first, the counter is inside a fixed panel of 200 units; the panel gives its child a tight constraint. In the second, the panel is only padding, and the request of the counter passes to the width of the panel.

```dart
// counter_model.dart — the two environments of the counter
import 'sizing.dart';

Part buildBounded(int want) => pad('Screen', 16, column('Body', [
      leaf('Title', 120),
      fixed('Panel', 200, leaf('Counter', want)),
    ]));

Part buildLoose(int want) => pad('Screen', 16, column('Body', [
      leaf('Title', 120),
      pad('Panel', 8, leaf('Counter', want)),
    ]));
```

The helper that counts the change matches the two records by path. It counts every node that is new or whose width changed.

```dart
// change_count.dart — counts the nodes whose width changed between two measurements
import 'sizing.dart';

int countChanged(List<Sizing> before, List<Sizing> after) {
  var changed = 0;
  for (final entry in after) {
    final match = before.where((old) => old.path == entry.path).toList();
    if (match.isEmpty || match.first.width != entry.width) {
      changed++;
    }
  }
  return changed;
}
```

## The Panel With a Tight Constraint

The first measurement prints the whole record of the bounded environment. The screen is 360 units and the counter requests 100 units.

```dart
// main.dart
import 'sizing.dart';
import 'counter_model.dart';

void main() {
  final log = measure(buildBounded(100), 360);
  printSizes(log);
}
```

```text
forced: /Screen width=360 constraint=360..360
forced: /Screen/Body width=328 constraint=328..328
chosen: /Screen/Body/Title width=120 constraint=0..328
chosen: /Screen/Body/Panel width=200 constraint=0..328
forced: /Screen/Body/Panel/Counter width=200 constraint=200..200
```

The panel requests 200 units and receives 200 units: **chosen**. What the counter requests does not matter, because the panel gives it a tight constraint between 200 and 200. The width of the counter is determined not by the counter's request but by the panel: **forced**.

## When the Request Changes

Now the request of the counter rises from 100 to 300. In the bounded environment we compare the two records.

```dart
// main.dart
import 'sizing.dart';
import 'counter_model.dart';
import 'change_count.dart';

void main() {
  final before = measure(buildBounded(100), 360);
  final after = measure(buildBounded(300), 360);
  printSizes(after.where((entry) => entry.path.endsWith('Counter')).toList());
  print('changed: ${countChanged(before, after)}');
}
```

```text
forced: /Screen/Body/Panel/Counter width=200 constraint=200..200
changed: 0
```

The counter is 200 units in both measurements, and the width of no node has changed. The change stopped at the panel. The same idea holds in the real framework as well: a widget that gives a tight constraint does not carry the change beneath it upward. Which nodes will actually be laid out again is a separate matter and is not measured in this lesson.

## The Panel That Gives No Tight Constraint

We try the same change on a panel made of padding. Here the panel gives its child no tight constraint.

```dart
// main.dart
import 'sizing.dart';
import 'counter_model.dart';
import 'change_count.dart';

void main() {
  final before = measure(buildLoose(100), 360);
  final after = measure(buildLoose(300), 360);
  printSizes(after.where((entry) => entry.path.contains('Panel')).toList());
  print('changed: ${countChanged(before, after)}');
}
```

```text
chosen: /Screen/Body/Panel width=316 constraint=0..328
chosen: /Screen/Body/Panel/Counter width=300 constraint=0..312
changed: 2
```

The counter's request of 300 units fits between 0 and 312 and stays **chosen**. The panel wraps the width of its child with 8 units of padding on each side; when the counter changes, the width of the panel also rises from 116 to 316. Two nodes changed: the counter and the panel. Because the column body receives a tight constraint, the change did not climb as far as the body and the screen. The change climbed one step above the counter, to the panel, and stopped at the tight constraint in the body.

## Reading the Path of the Change

The two measurements send the same change down different paths. In the panel with a tight constraint, the path of the counter ends at the panel: the parent never asks for the request of the child, because it has already imposed the answer. In the loose panel, the parent depends on the size of the child; the width of the panel derives from the width of the counter, and the change climbs one step. The body cuts this chain, because the screen forces the width of the body.

This reading is useful when looking for a performance problem. If a widget changes often, first look at how far up it is depended on. If every parent above reads the size of its child, a small change can cause a large subtree to be laid out again. Placing a widget that gives a tight constraint in between shortens this chain; the price is that the width of the child is now determined by that widget, not by the child. Even if the counter requests 300 units, it stays at 200 units. So there is a trade-off between the performance gain and sizing freedom, and the way to see this trade-off is to look at which node is **forced**.

## Unnecessary Rebuilds and the Const Constructor

When a widget changes, the framework rebuilds the tree beneath it. If the subtree stays the same, this work is wasted. The documentation of the framework says that most of the rebuild work can be skipped for a widget built with an unchanging const constructor. The idea is simple: the part that does not change is not produced as a new object every time; the same constant object is used.

The snippet below shows the two forms side by side; it is framework code outside the measured model.

```dart
// Platform code: const constructor and a new object every time
Column(
  children: [
    const Text('Title'),
    Text('Counter: $counter'),
  ],
)
```

The first child never changes and is built with a const constructor. The second changes as the counter changes, so it cannot be constant. A const constructor reduces the rebuild work; it does not change the size, that is, the width. The question of who determines the width stays the same in this snippet.

## A Performance Problem Versus a Budget Overrun

In M31/K06, performance is asked as whether a budget has been exceeded; here the question is who determines the width, and there is no budget. A **clamped** widget is not counted as having exceeded a budget; its request does not fit the allowed range. In this lesson no time was written and none will be; what is written is how many nodes a change touches.

## Common Misconception

**A const constructor changes or preserves the width of a widget.** Both are wrong. A const constructor reduces the rebuild work; the question of who determines the width stays the same. Even if the counter were built with a const constructor, it would be 200 **forced** in `buildBounded` and 300 **chosen** in `buildLoose`.

## The Bounding Measurement

The last measurement ties the boundary rule to the difference between a tight constraint and a loose constraint. The same change runs in two environments, and only the number of changed nodes is printed.

```dart
// main.dart
import 'sizing.dart';
import 'counter_model.dart';
import 'change_count.dart';

void main() {
  final bounded = countChanged(measure(buildBounded(100), 360), measure(buildBounded(300), 360));
  final loose = countChanged(measure(buildLoose(100), 360), measure(buildLoose(300), 360));
  print('bounded: $bounded');
  print('unbounded: $loose');
}
```

```text
bounded: 0
unbounded: 2
```

The focus node is `/Screen/Body/Panel/Counter`. In the environment with a tight constraint it is **forced**, and the number of changed nodes is zero; in the loose environment it is **chosen**, and two nodes change. This lesson's sizing: **forced**. The width of the counter was determined not by the changing request but by the tight constraint of the panel.

**QR1.** The model builds this sizing's rule; the model is not the framework itself. In the model, a change is the comparison of two records and carries no time. The rebuild work, the object lifetime, and the relayout order of the framework are not in the model. The exact conditions of the relayout boundary are in the documentation of the framework; the model only illustrates the effect of a tight constraint.

## Summary

- This lesson's sizing: **forced**. The width of the counter was determined not by the changing request but by the tight constraint of the panel.
- A const constructor reduces the rebuild work; it does not change the rule that determines the size.
- A widget that gives a tight constraint stops the change beneath it from climbing; in the model the number of changed nodes was 0.
- Under a loose constraint, the same change altered two nodes.
- Clamped sizing is not a budget overrun; the records of this lesson carry no time.

## Next Step

Performance tuning targets the app's own tree. But an app does not consist only of its own code most of the time: packages written by others also enter the tree. The next lesson, `Package Management`, covers what happens in the tree when the version of a package changes.
