Tree Shaking
3 min read
Build & Compilation
Tree shaking works because Dart's whole-program AOT compiler can statically prove reachability — anything it can't reach from main() is provably dead code. What breaks it: dart:mirrors, reflection-style dynamic dispatch, or any pattern that hides call sites from the compiler.
The release-mode compiler (flutter build apk --release) builds a reachability graph starting from main():
- Follow every function/method/class actually referenced.
- Mark them as live.
- Everything else gets dropped from the final binary.
This is static — it depends entirely on the compiler being able to see what calls what. If you do anything dynamic enough that the compiler can't follow, tree shaking stops working for that subtree.
Same principle removes unused icon code-points: when you only use Icons.home, Flutter's --tree-shake-icons flag strips the rest of the Material/Cupertino icon font.
Code in action — what helps and hurts
// ✅ Static imports — compiler sees exactly what's used
import 'package:intl/intl.dart' show DateFormat;
final formatted = DateFormat.yMMMd().format(DateTime.now());
// ✅ Conditional imports — compiler picks one branch per platform
import 'stub.dart'
if (dart.library.io) 'mobile.dart'
if (dart.library.html) 'web.dart';
// ✅ Sealed / final classes — even more aggressive elimination possible
// ❌ dart:mirrors — disabled in Flutter, would block tree shaking entirely
// import 'dart:mirrors';
// ❌ Reflection via dynamic dispatch
void invoke(dynamic obj, String method, [List args = const []]) {
// compiler can't know which method ends up called → keeps everything
}
// ❌ Massive const tables of constructors looked up by string
final widgetByName = {
'home': () => HomeScreen(),
'settings': () => SettingsScreen(),
// ...used by string lookup at runtime
};
// All those classes are reachable, so they all ship. That's "correct" but bigger
// than constructor-injection alternatives.
Build flags worth knowing
# Standard release — tree shaking on by default
flutter build apk --release
# Tree-shake icon fonts (already default; flag exists to opt out)
flutter build apk --release --tree-shake-icons
# Inspect what shipped in your release binary
flutter build apk --analyze-size
--analyze-size produces a JSON breakdown of every package's contribution to the final binary — the fastest way to spot a fat dependency.
When tree shaking matters most
| Symptom | Likely cause |
|---|---|
| Release APK suddenly 5MB larger | New package with broad surface area, or reflection-style code |
| Icon font is full size even though you only use 10 icons | Custom IconData constructed dynamically — --tree-shake-icons can't follow |
| Unused exports still appearing in size analyzer | Re-exported via library_name.dart barrel files that pull in everything |
| Web bundle huge | Web has different tree-shaking story — be ruthless with imports |
Common mistakes to avoid
// ❌ Importing whole packages when you need one symbol
import 'package:fancy_lib/fancy_lib.dart'; // pulls everything
// ✅ import 'package:fancy_lib/fancy_lib.dart' show DateFormat;
// ❌ Building IconData dynamically
IconData(0xe88a, fontFamily: 'MaterialIcons'); // breaks --tree-shake-icons
// ✅ Use the static Icons.home consts so the compiler sees the codepoint
// ❌ Reflection-style lookup tables for routes / widgets
final pages = <String, WidgetBuilder>{'home': ...}; // works, but ships everything
// ✅ For routes, use go_router / typed navigation — the compiler can prove usage
// ❌ "Just in case" exports in barrel files
export 'src/internal/legacy_v1.dart'; // ships unused legacy
// ✅ Export only what consumers should see
// ❌ Confusing release vs profile sizes
// Profile mode includes instrumentation; only measure release output
Interview follow-ups
-
Why doesn't tree shaking work in debug mode? Debug uses JIT compilation — code is loaded incrementally as it's needed, and hot reload requires keeping everything available. Tree shaking is an AOT optimisation that only runs on release builds where the whole program is compiled at once.
-
Why is
dart:mirrorsnot allowed in Flutter?mirrorslets you introspect and call anything by name at runtime — the compiler can't prove what's used, so nothing can be safely stripped. The cost in binary size and startup time made it incompatible with Flutter's goals; the library is unsupported on Flutter targets. -
How does
--tree-shake-iconsactually work? Flutter analyses everyIconData(codepoint, fontFamily: ...)reference in your code. Only those code-points are kept in the embedded icon font subset. If you constructIconDatafrom a non-const value, Flutter can't tell which glyphs are used and keeps the whole font. -
What's the difference between dead-code elimination and obfuscation? Dead-code elimination (tree shaking) removes unused code. Obfuscation renames what's left to opaque identifiers. They're independent — tree shaking shrinks the binary, obfuscation makes the remaining code harder to reverse-engineer. You usually run both for release.
How helpful was this content?
Please sign in to rate this article.