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

const Constructors & Compile-Time Constants: Flutter's Free Optimization

DartPerformanceFlutter

const Constructors & Compile-Time Constants

This is Part 4 of the Dart Internals & Performance series. In Part 3 we saw that allocation and GC, while cheap, aren't free. This part is about making them literally free for a huge class of objects — by building them at compile time instead of runtime.

const looks like a beginner topic ("it's just a stronger final, right?"). It isn't. const reaches all the way down into Part 1's snapshot and Part 3's GC, and it's the single most repeated performance tip in Flutter for a reason most developers can't fully explain. Let's fix that.

Assumes the snapshot/heap model from Part 1 and GC from Part 3. We're on Dart 3.12.


final vs const: a difference of time, not just mutability

Both final and const give you a variable you can't reassign. The difference is when the value is computed:

| | final | const | | --- | --- | --- | | Reassignable? | No | No | | Value known... | at runtime (first access) | at compile time | | Deeply immutable? | The reference is; the object may mutate internally | Fully, transitively immutable | | Can use DateTime.now()? | ✅ Yes | ❌ No (not known at compile time) |

final now = DateTime.now();        // ✅ computed when this line runs
const pi = 3.14159;                // ✅ baked in by the compiler

const bad = DateTime.now();        // ❌ compile error: not a constant expression
final list = const [1, 2, 3];      // a runtime variable pointing at a const list

The mental model: final means "assigned once, at runtime." const means "known to the compiler, frozen forever, before the program even starts." const is a much stronger promise — and the compiler rewards you for it.


What const actually does: build the object at compile time

A const constructor lets you construct an object (not just a number or string) entirely at compile time. The compiler evaluates it once, builds the instance, and bakes it into the snapshot's pre-initialized heap (remember from Part 1: the AOT snapshot ships a heap of already-built objects).

class Point {
  final int x, y;
  const Point(this.x, this.y); // const constructor
}

void main() {
  const origin = Point(0, 0); // built at COMPILE time, not when main runs
  print('${origin.x}, ${origin.y}');
}

The consequence is profound: const Point(0, 0) costs zero allocation at runtime. The object already exists in the snapshot. The running program just points at it. No new, no heap bump in Part 3's new space, no garbage to collect later. It's the closest Dart gets to a free object.

Requirements for a const constructor

To make a constructor const, the class must be deeply immutable:

  1. Every instance field is final.
  2. The constructor body is empty (initialization only via the initializer list / field initializers).
  3. It doesn't extend a class with a non-const generative constructor.

And to invoke it as const, every argument must itself be a constant expression.

class Circle {
  final double radius;
  const Circle(this.radius);
}

const r = 2.0;
const c1 = Circle(r);          // ✅ r is const
final dynamicR = readRadius();
final c2 = Circle(dynamicR);   // ⚠️ legal, but NOT const — built at runtime
// const c3 = Circle(dynamicR); // ❌ error: argument isn't a constant

Key: a const constructor enables compile-time construction; it doesn't force it. Circle(dynamicR) uses the same constructor but builds at runtime because its argument isn't constant. The const keyword at the call site (or context) is what requests the compile-time version.


Canonicalization: identical constants are the same object

Here's the property that powers the Flutter optimization. When you create the same const value twice, Dart doesn't make two objects — it canonicalizes them to one shared instance.

const a = Point(1, 2);
const b = Point(1, 2);

print(identical(a, b)); // true — literally the SAME object in memory

Compare that to runtime construction:

final c = Point(1, 2);   // (imagine Point had a non-const path)
final d = Point(1, 2);
print(identical(c, d));  // false — two separate allocations

Canonicalization means: for a given const expression, there is exactly one instance in the whole program, deduplicated by the compiler. const ['a', 'b'] written in fifty files is one list. const SizedBox(height: 8) used a thousand times is one SizedBox.

This isn't just a memory saving (though it is — one object instead of thousands). It's the hook Flutter uses to skip work.


Why Flutter begs you to use const

Flutter's build() runs constantly (Part 3). When the framework rebuilds a widget, it compares the new widget to the old one to decide whether the underlying render object needs updating. Two things make const a superpower here:

1. const widgets are created once. A const widget in a build() method isn't re-allocated every frame — it's the same canonical instance every time. Zero allocation, zero GC pressure, every single rebuild.

@override
Widget build(BuildContext context) {
  return Column(
    children: [
      const Text('Title'),          // ✅ same instance every rebuild — never re-created
      const SizedBox(height: 8),    // ✅ one canonical SizedBox for the whole app
      Text('Count: $count'),        // changes each frame — NOT const (depends on state)
    ],
  );
}

2. Flutter can short-circuit the rebuild. When the parent rebuilds, Flutter checks each child. If the new child widget is identical() to the old one — which a canonicalized const widget always is — Flutter knows nothing changed and can skip re-building that subtree entirely.

The payoff: marking a static subtree const means Flutter (a) never re-allocates it and (b) can prune it from the rebuild walk because the instance is unchanged. On a complex screen, liberal const measurably cuts both allocation churn and rebuild work. This is why prefer_const_constructors is a default lint and why the IDE nags you with the blue "Prefer const" hint.

// ❌ Rebuilt and re-allocated every frame, even though it never changes:
Icon(Icons.star, color: Colors.amber)

// ✅ One canonical instance; skipped on rebuild:
const Icon(Icons.star, color: Colors.amber)

const collections and nested const

Collections and nested objects can be const too — and const is transitive: a const collection's elements must themselves be constant.

const sizes = [8.0, 16.0, 24.0];                 // const List
const config = {'retries': 3, 'timeout': 30};    // const Map
const themeColors = {Colors.red, Colors.blue};   // const Set

// Nested: the whole tree is built at compile time and canonicalized.
const card = CardData(
  title: 'Hello',
  points: [1, 2, 3],          // inner list is const too
);

A neat trick: inside an already-const context, you don't repeat const — it propagates downward.

// One outer const; the inner EdgeInsets and TextStyle are implicitly const.
const padding = Padding(
  padding: EdgeInsets.all(8),       // implicitly const here
  child: Text('Hi', style: TextStyle(fontSize: 14)), // implicitly const
);

Const in switch, defaults, and annotations

Because they're compile-time values, constants unlock things runtime values can't:

const small = 8.0;
const large = 24.0;

// Constants are required for switch case labels and enum-like dispatch.
String label(double size) => switch (size) {
  small => 'small',   // case values must be constant
  large => 'large',
  _ => 'custom',
};

// Default parameter values must be constant.
void box({double size = small}) {} // ✅ 'small' is const

// Annotations are constant expressions.
class Service {
  @Deprecated('Use v2') // a const annotation
  void oldMethod() {}
}

If you've ever hit "Case expressions must be constant" or "Default value must be constant," now you know why: those positions are evaluated by the compiler, so they demand compile-time values.


Gotchas worth internalizing

1. const is contagious in a good way — but check your fields. One non-final field, and the whole class can't have a const constructor.

class Bad {
  int count = 0;          // ❌ not final → can't be const
  const Bad();            // compile error
}

2. Equality of const objects is identity-cheap. Because identical const objects are the same instance, identical() and even == short-circuit instantly. (Flutter relies on this.)

3. const requires known values — generics included. A type variable isn't always const-constructible; arguments must be constants at the call site.

4. Don't fake it with final. final x = const Foo() gives you a runtime variable pointing at a const object — fine — but final x = Foo() (no const) allocates at runtime. The const keyword has to be there to get the compile-time object.

5. const ≠ faster code, it means no code. The win isn't a faster constructor; it's eliminating the construction at runtime entirely, plus deduplication, plus letting Flutter skip rebuilds.


Practice Challenges

Challenge 1 — final or const? Which compile, and which don't?

final a = DateTime.now();
const b = DateTime.now();
const c = [1, 2, 3];
final d = const {'x': 1};
Show solution

a ✅ (runtime value, fine for final). b ❌ — DateTime.now() isn't a constant expression. c ✅ — list of constants. d ✅ — a final variable pointing at a const map. Only b fails.

Challenge 2 — Make it const-able. This class can't have a const constructor. Fix it.

class Vector {
  double x, y;
  Vector(this.x, this.y);
}
Show solution

Make the fields final and the constructor const:

class Vector {
  final double x, y;
  const Vector(this.x, this.y);
}

A const constructor requires every instance field to be final (deeply immutable).

Challenge 3 — Predict identity. What do these print?

const p = Point(1, 1);
const q = Point(1, 1);
final r = Point(1, 1); // assume a const constructor exists
print(identical(p, q));
print(identical(p, r));
Show solution

true, then false. p and q are both compile-time constants → canonicalized to one instance. r is built at runtime (no const at the call site), so it's a separate object, not identical to the canonical p.

Challenge 4 — Flutter rebuild. Why is const SizedBox(height: 8) better than SizedBox(height: 8) inside a frequently-rebuilt build()?

Show solution

The const version is a single canonical instance reused every rebuild — no re-allocation and no GC pressure — and because the new child is identical() to the old one, Flutter can skip re-building that subtree. The non-const version allocates a fresh SizedBox every frame and can't be pruned as trivially.

Challenge 5 — Where does the const object live? In a release (AOT) build, where does const Point(0, 0) physically come from at runtime, and what does that save?

Show solution

It's built at compile time and stored in the AOT snapshot's pre-initialized heap (Part 1). At runtime the program just references it — zero allocation, no new-space bump, and nothing for the GC to collect (Part 3).


Questions to test yourself

Q1 (basic). State the core difference between final and const.

Show answer

final is assigned once at runtime; const is a compile-time constant (known and built before the program runs) and is deeply, transitively immutable.

Q2 (basic). What are the requirements for a class to have a const constructor?

Show answer

All instance fields must be final, the constructor body must be empty (init via initializer list/field initializers), and it mustn't call a non-const superclass generative constructor. To invoke it as const, all arguments must be constant expressions.

Q3 (intermediate). What is canonicalization and why does it matter?

Show answer

For a given const expression, Dart creates exactly one shared instance and reuses it everywhere — identical const values are the same object (identical() is true). It saves memory (dedup) and, crucially, lets Flutter detect "unchanged" widgets by identity and skip rebuilding them.

Q4 (intermediate). Does a const constructor force compile-time construction? Explain with Foo(dynamicValue).

Show answer

No. A const constructor enables compile-time construction but only when invoked in a const context with constant arguments. Foo(dynamicValue) uses the same constructor but builds at runtime because the argument isn't constant. You need const at the call site (or an enclosing const context) and constant arguments to get the compile-time object.

Q5 (advanced). Explain the two distinct ways const improves Flutter performance.

Show answer

(1) No allocation: a const widget is a canonical instance built once (into the snapshot heap), so it's never re-allocated across rebuilds — zero GC churn. (2) Skipped rebuilds: because the rebuilt child is identical() to the previous one, Flutter can prune that subtree from the rebuild/diff walk entirely. Together: less allocation and less work per frame.

Q6 (advanced). Why must switch case labels and default parameter values be const?

Show answer

Both are resolved by the compiler, not at runtime: case labels are compiled into constant dispatch and must be known/comparable at compile time, and default values are baked into the method's signature. Non-constant expressions can't be evaluated then, so the compiler requires constant expressions in those positions.


Wrapping up

  • final is "assigned once at runtime"; const is "known and built at compile time," deeply immutable.
  • A const constructor (all fields final, empty body) lets you build whole objects at compile time, baked into the snapshot heap — zero runtime allocation.
  • Identical constants are canonicalized to one shared instance (identical() is true), deduplicating memory.
  • Flutter exploits this twice: const widgets are never re-allocated, and their identity lets the framework skip rebuilding unchanged subtrees — hence prefer_const_constructors.
  • const propagates through nested collections/objects and is required in switch cases, default values, and annotations.

In Part 5 we cross the boundary out of the managed Dart world entirely and call native machine code directly: Dart FFI — invoking C functions, mapping types and structs, and managing memory the GC won't touch for you.