| |

Dart 83 🎯 Optimizing Tree Shaking and Reducing AOT Binary Snapshot Size

⏱️ Estimated read: 17 min | |

Tree shaking is the Dart AOT compiler’s mechanism for removing code that cannot be reached from any entry point. It is not an optional optimization you enable; it runs automatically in profile and release builds, and it is the primary reason a Flutter release binary is orders of magnitude smaller than the sum of every package in pubspec.yaml. But tree shaking has limits, and understanding those limits is the difference between a binary that is merely compiled and a binary that is genuinely optimized.

The AOT snapshot itself is a serialized heap graph: a data section containing strings, constants, class metadata, and function objects, plus a code section containing generated machine code . Tree shaking operates on this graph by starting from roots (the main function, annotated entry points, and runtime-required stubs) and retaining only what is transitively reachable. Everything else is discarded before the snapshot is written. When the output is still too large, the problem is rarely that tree shaking failed—it is that something pulled unnecessary code into the reachable set, or that the data section contains structures the compiler cannot eliminate.

This chapter covers how tree shaking identifies roots and traces reachability, how to inspect what survived using the app size tool’s dominator tree and call graph, the flags that reduce binary size without changing code (--split-debug-info, --obfuscate), the compiler options that trade size for other properties, and the common patterns that defeat tree shaking by making code artificially reachable.

Key point: Tree shaking removes unreachable code, not unused code. The distinction matters: a function you never call but that is referenced from a retained class or a @pragma('vm:entry-point') annotation will survive, and the dominator tree tells you exactly why.


Why AOT size optimization exists

The snapshot is a heap graph, not a zip file. An AOT snapshot contains a serialized representation of the Dart VM heap at the moment compilation finishes . It holds every object the compiler decided to retain: class descriptors, function objects, constant pools, string tables, and machine code. Because the snapshot is a graph rather than a linear instruction stream, “removing code” means removing nodes and severing edges before serialization. Tree shaking is graph pruning, and its effectiveness depends entirely on how many nodes are reachable from the root set.

Roots determine everything. The compiler begins with a small set of roots: the main entry point, any function annotated with @pragma('vm:entry-point'), runtime stubs the VM requires, and reflection-related metadata if reflection is used . Every retained node is reachable from at least one root. Code that is never called and never referenced from a retained node is unreachable and therefore discarded. Code that is never called but is referenced—through a static field, a registration mechanism, or an annotation—remains. This is why “unused” code sometimes survives: it is unused by the programmer but reachable by the compiler’s analysis.

The data section resists elimination. Executable code typically constitutes roughly half of an AOT snapshot; the rest is data . Strings, constant instances, class metadata, and runtime tables cannot always be eliminated even when the code that used them is gone, because they may be referenced from other retained structures. A large string constant referenced from a retained class descriptor survives even if the method that originally used it was tree-shaken. Size optimization therefore has two fronts: reducing reachable code and reducing reachable data.

Debug builds provide no signal. The AOT compiler and its tree shaking run only in profile and release modes. Debug builds are JIT-compiled and include VM overhead, assertion machinery, and no tree shaking at all . Measuring the size of a debug build tells you nothing about the optimized binary. Any size optimization effort must begin with a profile or release build, and the app size tool consumes only size information generated from those builds.

The trade-off. Aggressive tree shaking can break code that relies on dynamic lookup, reflection, or runtime type checks that the compiler cannot see. Dart mitigates this with @pragma('vm:entry-point'), which marks functions as roots even when no static reference exists . The trade-off is explicit: annotate what the compiler cannot infer, or lose it to the tree shaker. Over-annotation defeats the purpose; under-annotation produces runtime failures that only appear in release builds.


a. How tree shaking traces reachability

The AOT compiler performs tree shaking during precompilation, after the class hierarchy has stabilized enough for Class Hierarchy Analysis (CHA) . The process has a clear sequence: collect roots, trace callers and callees from those roots, retain every reachable function and the data it references, then discard the rest.

Root collection begins with annotated entry points. Every function marked with @pragma('vm:entry-point') is added to the root set regardless of whether any Dart code calls it . This annotation exists precisely because tree shaking would otherwise remove functions invoked only through native code, platform channels, or reflection-like mechanisms. The compiler also adds VM-internal stubs, type-checking helpers, and the main function itself.

Tracing proceeds through the call graph. For each retained function, the compiler examines its callees and adds them to the retained set if they are not already present. This process iterates until no new functions are added. Static field references, constant pool entries, and class instantiation sites all create edges in this graph. A function that is never called but is assigned to a static field in a retained class becomes reachable through that field, and therefore survives.

// A function that tree shaking will remove because it is
// never called and never referenced from a retained node.
void _neverCalledHelper() {
  // This body is discarded during AOT compilation.
  // It appears in no snapshot, consumes no bytes.
  print('This string may still survive if referenced elsewhere.');
}

// A function that survives despite never being called directly,
// because the annotation marks it as a root.
@pragma('vm:entry-point')
void platformCallback() {
  // Invoked from native code or a platform channel.
  // Tree shaking retains it because the pragma adds it to the root set.
  print('Retained by annotation');
}

void main() {
  // _neverCalledHelper is absent from the snapshot.
  // platformCallback is present despite no call site here.
  print('main executed');
}

The string literal inside _neverCalledHelper is a separate question. If the string is referenced only from that function’s constant pool, and the function is removed, the string may also be removed. But if the same string appears elsewhere in retained code, or if string deduplication merged it with a retained string, it survives. This is why size attribution is not perfectly precise: bitwise-equivalent objects are deduplicated, and a byte cannot always be traced to a single source .


b. Reading the dominator tree and call graph

The app size tool’s Analysis tab presents the compiled snapshot as a treemap, a table, a dominator tree, and a call graph . The treemap and table show sizes hierarchically by package, library, class, and function. The dominator tree and call graph answer a different question: why is this code present?

The dominator tree is built on the principle that node A dominates node B if every path from the root to B passes through A . In size analysis, this means A is the single point through which B becomes reachable. If you find an unexpected package in your binary, the dominator tree traces it to the import or reference that pulled it in. The call graph shows the many-to-many relationships instead: every caller of a function and every function it calls . Use the dominator tree when you want the root cause of inclusion; use the call graph when you want to understand the fine-grained dependency structure .

// Suppose package:analytics is unexpectedly large in the binary.
// The dominator tree might show:
//   package:my_app → package:my_app/src/feature.dart
//     → package:analytics → package:analytics/src/heavy_reporter.dart
//
// This means feature.dart imports analytics, and every path to
// heavy_reporter passes through feature.dart. The fix is to remove
// or lazy-load the analytics import in feature.dart, not to
// modify analytics itself.
//
// The call graph would additionally show every function in
// feature.dart that calls into analytics, revealing whether
// the dependency is used broadly or from a single method.

Generating a size file requires the --analyze-size flag on a Flutter build, which produces a JSON file containing the size information . For pure Dart AOT binaries, the --extra-gen-snapshot-options=--write-v8-snapshot-profile-to=profile.json flag captures the snapshot profile, which the snapshot_analysis CLI can summarize by package, class, or method . The summary subcommand produces tables like the dart:core breakdown showing _Uri at 15.53% of library bytes, revealing which internal classes dominate the data section .


c. Size flags and compiler options

Three flags reduce binary size without requiring code changes: --split-debug-info, --obfuscate, and the AOT compiler’s extra snapshot options.

--split-debug-info stores Dart program symbols in a separate directory on the build host rather than embedding them in the binary . The binary itself becomes smaller because debug symbol tables are removed. Stack traces from the stripped binary require the symbol file and the flutter symbolize command to become human-readable . This flag is the single highest-leverage size reduction available without touching source code, because debug information is large and never needed in production. It is commonly combined with --obfuscate, which additionally replaces identifier names with short meaningless strings, reducing the data section’s string table.

# Build a release APK with debug symbols stripped and names obfuscated.
# The symbol file is written to build/app/outputs/symbols for later
# stack trace symbolization.
flutter build apk --obfuscate --split-debug-info=build/app/outputs/symbols

For pure Dart AOT compilation, dart compile exe accepts --extra-gen-snapshot-options to pass flags directly to the snapshot generator . The --print-instructions-sizes-to flag attributes executable code bytes to originating functions, and --write-v8-snapshot-profile-to produces a graph representation covering both code and data . These flags produce the raw data that snapshot_analysis consumes, enabling the same kind of attribution that Flutter’s app size tool provides.

// A Dart CLI application compiled with snapshot profiling:
//
// dart compile exe \
//   --extra-gen-snapshot-options=--write-v8-snapshot-profile-to=profile.json \
//   -o my_app my_app.dart
//
// Then analyze the profile:
//
// pub global activate vm_snapshot_analysis
// snapshot_analysis summary -b class profile.json
//
// The output shows which classes account for the most bytes,
// allowing you to target the largest contributors for removal
// or refactoring.

Complete Example Session

// ============================================
// PART 1: CODE THAT TREE SHAKING REMOVES
// ============================================
// This function is never called and never referenced from
// any retained node. It is unreachable and will not appear
// in the AOT snapshot.
void _deadCode() {
  final data = List.generate(1000, (i) => 'unused_$i');
  print(data.length);
}

// ============================================
// PART 2: CODE RETAINED BY ANNOTATION
// ============================================
// Called only from native code or a platform channel.
// Without the pragma, tree shaking would remove it.
@pragma('vm:entry-point')
void nativeEntryPoint() {
  // Retained because the pragma adds it to the root set.
  print('called from native');
}

// ============================================
// PART 3: CODE RETAINED BY STATIC REFERENCE
// ============================================
// Never called, but assigned to a static field that is
// reachable from a retained class. The field reference
// makes the closure reachable, so it survives.
final _callbacks = <void Function()>[];

void registerUnusedCallback() {
  _callbacks.add(() => print('retained by field reference'));
}

// ============================================
// PART 4: THE MAIN ENTRY POINT
// ============================================
void main() {
  // _deadCode is absent from the snapshot.
  // nativeEntryPoint is present despite no call here.
  // The closure in registerUnusedCallback is present
  // if _callbacks is reachable (it is, as a top-level field).
  registerUnusedCallback();
  print('main executed');
}

// ============================================
// PART 5: INSPECTING THE SNAPSHOT
// ============================================
// Compile with:
// dart compile exe \
//   --extra-gen-snapshot-options=--write-v8-snapshot-profile-to=profile.json \
//   -o my_app main.dart
//
// Then:
// snapshot_analysis summary -b function profile.json
//
// _deadCode does not appear.
// nativeEntryPoint appears (annotation root).
// The closure from registerUnusedCallback appears
// because _callbacks is a retained field.

// ============================================
// PART 6: THE DOMINATOR TREE EXPLANATION
// ============================================
// If an unexpected function appears in the summary, the
// dominator tree in Flutter's app size tool traces it
// to the single node through which it becomes reachable.
// For a Dart CLI, the --trace-precompiler-to flag
// records dependencies between compiled functions,
// showing why a function was pulled into the snapshot.

// ============================================
// PART 7: SPLITTING DEBUG INFO
// ============================================
// Release builds include debug symbols by default.
// Strip them with --split-debug-info:
//
// flutter build apk --split-debug-info=build/symbols
//
// The binary loses symbol tables; the symbols directory
// gains the information needed for stack trace symbolization.
// Binary size decreases; debuggability requires the symbols file.

// ============================================
// PART 8: OBFUSCATION
// ============================================
// Combine with --obfuscate to replace identifier names
// with short strings, shrinking the string table:
//
// flutter build apk --obfuscate --split-debug-info=build/symbols
//
// The trade-off: stack traces require symbolization, and
// runtimeType.toString() returns minified names.

// ============================================
// PART 9: ANALYZING THE DIFF
// ============================================
// The app size tool's Diff tab compares two size files
// from the same app at different versions.
// Build before and after a refactor, generate size files
// for each, and diff them to see which packages grew or shrank.
// This is the feedback loop for size optimization:
// measure, change, measure again.

// ============================================
// PART 10: SUMMARY OF THE WORKFLOW
// ============================================
// 1. Build in profile/release mode (tree shaking only runs here).
// 2. Generate a size file (--analyze-size for Flutter).
// 3. Load into app size tool; inspect treemap for largest nodes.
// 4. Use dominator tree to find why unexpected code is included.
// 5. Remove the reference or import that pulls it in.
// 6. Strip debug info and obfuscate for final release builds.
// 7. Diff size files before and after changes to verify impact.

This session demonstrates the three fates of code under tree shaking: removal (_deadCode), retention by annotation (nativeEntryPoint), and retention by static reference (registerUnusedCallback‘s closure). The workflow moves from compilation flags through analysis tools to the optimization loop of measure-change-measure.


Quick Reference

Tree Shaking Retention Mechanisms

MechanismExampleSurvives?
Called from mainmain() { helper(); }Yes
@pragma('vm:entry-point')Annotated functionYes
Static field referencestatic final _cb = () {};Yes
Instance field in retained classRetained object’s fieldYes
Never called, no referencevoid _dead() {}No

App Size Tool Views

ViewQuestion AnsweredBest For
TreemapWhat is largest?Visual size hierarchy
TableWhat are exact bytes?Precise measurements
Dominator TreeWhy is this included?Root cause of inclusion
Call GraphWhat are all dependencies?Fine-grained relationships

Size Reduction Flags

FlagEffectTrade-off
--split-debug-info=<dir>Removes symbol tables from binaryStack traces need symbolization
--obfuscateMinifies identifier namesruntimeType returns minified names
--analyze-sizeGenerates size JSON for DevToolsNone (analysis only)

Compiler Snapshot Flags

FlagOutputUse
--print-instructions-sizes-toCode bytes per functionCode attribution
--write-v8-snapshot-profile-toGraph of heap nodesFull snapshot attribution
--trace-precompiler-toFunction dependenciesWhy code was retained

Build Modes and Tree Shaking

ModeCompilerTree ShakingValid for Size?
DebugJITNoNo
ProfileAOTYesYes
ReleaseAOTYesYes

Best Practices

✅ Do This:

// Measure profile or release builds only.                  // ✅
flutter build apk --analyze-size --target-platform=android-arm64

// Annotate native-facing functions explicitly.              // ✅
@pragma('vm:entry-point')
void platformHandler() { ... }

// Strip debug info for release builds.                      // ✅
flutter build apk --split-debug-info=build/symbols

// Use dominator tree to find inclusion root cause.          // ✅
// Load size file, inspect dominator tree for unexpected code.

// Diff size files before and after refactors.               // ✅
// Compare old and new JSON in the Diff tab.

// Remove unused imports that pull in packages.              // ✅
// import 'package:heavy/unused.dart'; // delete this line.

❌ Don’t Do This:

// Measure debug build size.                                // ❌
flutter build apk --debug; // No tree shaking, includes VM overhead.

// Assume unused code is always removed.                     // ❌
final _unused = () { heavyWork(); }; // Retained by field reference.

// Over-annotate entry points.                              // ❌
@pragma('vm:entry-point') void everyMethod() {} // Defeats tree shaking.

// Skip symbol preservation when stripping.                 // ❌
flutter build apk --split-debug-info=build/symbols;
// Without saving symbols, release crashes are unreadable.

// Ignore the data section.                                 // ❌
// Removing code without checking constant/string retention.

// Use reflection without considering tree shaking.         // ❌
// dart:mirrors prevents aggressive tree shaking; avoid in AOT.

Common Pitfalls

PitfallWhy It HappensFix
Measuring debug build sizeDebug is JIT, no tree shakingUse --profile or --release
Unused code survivesReferenced from retained static fieldRemove the field reference
Annotation overuseEvery method marked entry-pointAnnotate only native-facing code
Symbols lost after strip--split-debug-info without savingPreserve symbols directory
Unexpected package includedTransitive import from a retained fileDominator tree traces the import
Data section dominates sizeLarge constants/strings retainedAudit constant pools and deduplication

Real-World Examples

1. Removing a Dead Function

void _neverUsed() { print('gone'); }  // Absent from snapshot.

2. Retaining a Native Callback

@pragma('vm:entry-point')
void onPlatformMessage() { ... }      // Present despite no call site.

3. Static Field Retaining a Closure

final _handlers = <void Function()>[];
void reg() => _handlers.add(() {});    // Closure survives via _handlers.

4. Splitting Debug Info

flutter build apk --split-debug-info=build/symbols

5. Obfuscating Release Build

flutter build apk --obfuscate --split-debug-info=build/symbols

6. Analyzing a Size File

flutter build apk --analyze-size --target-platform=android-arm64
# Load build/apk-code-size-analysis_01.json into DevTools.

7. Snapshot Profile for Dart CLI

dart compile exe --extra-gen-snapshot-options=--write-v8-snapshot-profile-to=profile.json -o app main.dart

8. Summary by Class

snapshot_analysis summary -b class profile.json

9. Diffing Two Builds

# Build before refactor, generate size file.
# Build after refactor, generate size file.
# Compare in DevTools Diff tab.

10. Removing an Unused Import

// Before: import 'package:heavy/analytics.dart';
// After: deleted; dominator tree no longer shows analytics.

Visual

Tree Shaking Root Propagation

┌──────────────────────────────────────────────┐
│  ROOT SET → REACHABLE SET                    │
│                                              │
│  Roots:                                      │
│    main()                                    │
│    @pragma('vm:entry-point') functions       │
│    VM internal stubs                         │
│         │                                    │
│         ▼                                    │
│    Trace callees, field refs, constants      │
│         │                                    │
│         ├──► retained function A             │
│         │         └──► retained function B   │
│         │                                    │
│         └──► retained static field           │
│                   └──► retained closure      │
│                                              │
│  Everything else: discarded before           │
│  snapshot serialization.                     │
│                                              │
└──────────────────────────────────────────────┘

Dominator Tree Query

┌──────────────────────────────────────────────┐
│  WHY IS package:unexpected INCLUDED?         │
│                                              │
│  package:my_app                              │
│       │                                      │
│       ▼                                      │
│  feature.dart (imports unexpected)           │
│       │                                      │
│       ▼                                      │
│  package:unexpected                          │
│       │                                      │
│       ▼                                      │
│  heavy_reporter.dart (large)                 │
│                                              │
│  Every path to heavy_reporter passes         │
│  through feature.dart. Fix the import        │
│  in feature.dart, not the package.           │
│                                              │
└──────────────────────────────────────────────┘

Snapshot Composition

┌──────────────────────────────────────────────┐
│  AOT SNAPSHOT SECTIONS                       │
│                                              │
│  ┌────────────────────────────────────┐      │
│  │  CODE SECTION (~50%)               │      │
│  │  Machine code, stubs, compiled     │      │
│  │  functions. Reduced by tree shaking│      │
│  │  and --print-instructions-sizes-to │      │
│  └────────────────────────────────────┘      │
│                                              │
│  ┌────────────────────────────────────┐      │
│  │  DATA SECTION (~50%)               │      │
│  │  Strings, constants, class metadata│      │
│  │  Deduplicated but not always       │      │
│  │  removable. Reduced by --obfuscate │      │
│  └────────────────────────────────────┘      │
│                                              │
│  --split-debug-info removes symbols from     │
│  both sections before writing.               │
│                                              │
└──────────────────────────────────────────────┘

Size Optimization Loop

┌──────────────────────────────────────────────┐
│  MEASURE → ANALYZE → CHANGE → MEASURE        │
│                                              │
│  Build profile/release                       │
│       │                                      │
│       ▼                                      │
│  Generate size file (--analyze-size)         │
│       │                                      │
│       ▼                                      │
│  Treemap: find largest nodes                 │
│  Dominator tree: find inclusion cause        │
│       │                                      │
│       ▼                                      │
│  Remove import, field, or annotation         │
│       │                                      │
│       ▼                                      │
│  Rebuild, diff size files, verify delta      │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Tree shakingAOT removal of unreachable code
Valid build modesProfile and release only
Root setmain, entry-point pragmas, VM stubs
Retention mechanismReachability from a root
Dominator treeRoot cause of code inclusion
Call graphMany-to-many dependency relationships
--split-debug-infoStrips symbols, reduces binary size
--obfuscateMinifies identifiers
--analyze-sizeGenerates size JSON for DevTools
Snapshot composition~50% code, ~50% data
DeduplicationBitwise-identical objects shared

Key takeaways:

  • Tree shaking removes unreachable code, not unused code. A function never called but referenced from a retained static field survives. The dominator tree explains why.
  • Only profile and release builds are valid for size analysis. Debug builds are JIT-compiled and include overhead that distorts every measurement .
  • The dominator tree answers “why is this included?” It traces any node to the single reference or import through which it becomes reachable, which is where the fix belongs.
  • --split-debug-info is the highest-leverage flag. Debug symbols are large and unnecessary in production; stripping them requires preserving the symbols directory for later symbolization .
  • The data section resists elimination. Strings, constants, and metadata survive even when the code that used them is gone. Obfuscation reduces identifier strings; auditing constant pools reduces the rest.
  • Annotation is a scalpel, not a hammer. @pragma('vm:entry-point') exists for code invoked outside Dart’s static analysis. Marking everything defeats tree shaking.
  • The optimization loop is measure-analyze-change-measure. Generate a size file, inspect the treemap for large nodes, use the dominator tree for inclusion causes, make one change, and diff the size files.
  • Dart CLI binaries use the same mechanisms. --write-v8-snapshot-profile-to and snapshot_analysis provide the same attribution for pure Dart AOT executables as Flutter’s app size tool provides for mobile apps .

Remember: The AOT snapshot is a pruned heap graph, and tree shaking is the pruning mechanism. It starts from a small root set and retains only what is reachable. When your binary is larger than expected, the tree shaker did not fail—something made code reachable that you expected to be dead. The dominator tree identifies that something. The size optimization workflow is a loop: build in release, generate a size file, find the largest reachable nodes, trace their inclusion to a reference or import, remove the reference, and measure again. Flags like --split-debug-info and --obfuscate reduce size without touching code, but they do not change what is reachable. Reachability is the compiler’s decision, and the only way to change it is to change what the compiler can see.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!