JIT vs AOT
This is Part 2 of the Dart Internals & Performance series. In Part 1 we mapped the whole pipeline and met Dart's "two engines." Now we put them head-to-head.
Here's the thing almost every Flutter tutorial glosses over: Flutter's two signature feelings come from two different compilers. That magical sub-second hot reload? JIT. That smooth, fast app your users download? AOT. Same Dart, same code, two compilation strategies — and Flutter is one of the very few mainstream stacks that uses both, switching automatically based on whether you're developing or shipping.
Understanding the difference isn't trivia. It explains why hot reload vanishes in release mode, why your release app starts faster than your debug app, why binary sizes are what they are, and how to reason about performance the way the framework authors do.
This part assumes the pipeline from Part 1 — kernel, snapshots, the shared runtime. We're on Dart 3.12.
The core distinction in one line
JIT compiles while the program runs. AOT compiles before the program runs.
Everything else — hot reload, startup speed, binary size, warm-up — falls out of that single difference in timing. Let's build the intuition, then get precise.
Analogy — the translator. You've hired someone to help you give a speech in a language you don't speak.
- A JIT translator stands next to you and translates live, sentence by sentence, as you speak. Huge advantage: if you change your speech on the fly, they just translate the new words — no prep wasted. Cost: there's a tiny lag on each new sentence, and they're doing work during the speech.
- An AOT translator takes your entire script the night before and hands you a fully translated copy. On stage you just read it — zero lag, perfectly smooth. Cost: you can't change a word during the speech, and they spent all night translating even the parts you might skip.
JIT = translate-as-you-go (flexible, slight runtime cost). AOT = translate-everything-upfront (rigid, zero runtime cost). Hold that picture.
How JIT actually works
In development, your kernel runs on the Dart VM, which does not compile everything to machine code up front. It compiles lazily and adaptively:
- Start fast, unoptimized. Code begins interpreted / baseline-compiled so it runs instantly — no waiting on a full compile.
- Profile at runtime. The VM counts how often each function runs and records the actual types flowing through it.
- Optimize hot code. When a function crosses a "hotness" threshold, the optimizing compiler turns it into specialized machine code, making bets based on what it observed (e.g. "this
numis always anint"). - Deoptimize on surprise. If a bet is later violated (a
doubleshows up), the VM deoptimizes — bails back to the safe, general version — and may re-optimize later.
// Development: the VM watches this run.
num add(num a, num b) => a + b;
void main() {
var sum = 0;
for (var i = 0; i < 2000000; i++) {
sum += add(i, 1) as int; // 'add' is hot; JIT specializes it for int
}
print(sum);
}
This adaptive approach is why JIT is fast to start iterating and can produce excellent steady-state code — but it carries a warm-up cost (early runs are slower until hot paths optimize) and needs the compiler present at runtime.
The superpower: hot reload
Because the JIT VM holds your program as mutable, live code built from kernel, it can accept new kernel mid-flight. That's hot reload:
On save, Flutter re-compiles only the changed libraries to a kernel delta, injects it into the running VM, rebuilds the widget tree, and keeps your app state. No restart, no losing your place.
// Change this text, hit save → the UI updates in well under a second,
// WITHOUT losing your navigation stack, scroll position, or state.
Text('Hello, internals!')
Hot reload is only possible with JIT, because only the JIT VM can swap code into a running program. This is the single biggest reason Flutter ships a JIT engine at all.
Remember: Hot reload ≠ hot restart. Reload injects new code and preserves state. Restart (
R) throws away state and re-runsmain(). Both need the JIT/debug build.
How AOT actually works
When you ship, the toolchain compiles all reachable code to native machine code ahead of time, producing an AOT snapshot (from Part 1). The shipped app contains the machine code and the embedded runtime — but no compiler.
dart compile exe bin/server.dart -o build/server
./build/server # no warm-up, no profiling — already optimized native code
Consequences, all flowing from "compiled before it runs":
- Instant startup, no warm-up. There's nothing to compile or profile at launch. Critical for a mobile app opened for a few seconds.
- Predictable performance. No mid-run deoptimization pauses; the code is fixed.
- No hot reload. You can't inject new code into a program that has no compiler and whose code is frozen.
- Larger binaries. Every reachable function's machine code ships, plus the runtime.
- Conservative optimization. The compiler never observed runtime types, so it can't speculatively specialize the way JIT does. It leans on whole-program analysis instead (see below).
Tree shaking and whole-program analysis
AOT has one big compensating advantage: it can see your entire program at once. So it performs tree shaking — dead-code elimination that drops every function, class, and field nothing reachable from main() actually uses.
String used() => 'kept';
String unused() => 'dropped'; // never called → tree-shaken out of the AOT build
void main() => print(used());
This is why pulling in a big library but using one function doesn't necessarily bloat your release app: the AOT compiler removes what you don't reach. (It also enables @pragma hints and devirtualization — turning polymorphic calls into direct ones when whole-program analysis proves only one target exists.)
Head-to-head
| Dimension | JIT (debug) | AOT (release) |
| --- | --- | --- |
| When compiled | At runtime, lazily | Ahead of time, fully |
| Startup | Slower (warm-up) | Fast (no warm-up) |
| Steady-state speed | Can be excellent (specialized) | Excellent, predictable |
| Hot reload | ✅ Yes | ❌ No |
| Debugging / DevTools | Full | Limited |
| Binary size | Smaller code, bigger VM | Bigger code, smaller runtime |
| Optimization basis | Observed runtime types | Whole-program static analysis |
| Determinism | Can deoptimize/re-optimize | Fixed code |
| Used for | flutter run, dart run | flutter build, dart compile exe |
The golden mapping: Debug = JIT = hot reload + slower. Release = AOT = no hot reload + faster. If you're benchmarking your app's performance in debug mode, you're measuring the JIT with assertions on and optimizations off — always profile in
--profileor release. This is the #1 mistake we'll revisit in Part 6.
"Why not just JIT everything?" (and why mobile said no)
If JIT can match AOT at steady state and gives hot reload, why ship AOT at all? Three reasons, and the first is non-negotiable on iOS:
- Platform policy. iOS forbids runtime code generation (executable, writable memory) for App Store apps. A JIT generates machine code at runtime — disallowed. AOT sidesteps this entirely: the code is already native, signed, and fixed.
- Startup latency. Users judge an app in the first second. Warm-up is unacceptable when someone opens your app to check one thing and closes it.
- Predictability + battery. No background re-compilation or deopt pauses; more consistent frame times; less CPU spent compiling.
So Flutter's choice is pragmatic: JIT where iteration speed wins (your machine), AOT where user experience wins (their device). You get both, automatically.
Flutter modes, concretely
Flutter exposes the trade-off as three build modes:
| Mode | Compiler | Hot reload | Asserts | Use it for |
| --- | --- | --- | --- | --- |
| Debug (flutter run) | JIT | ✅ | on | Day-to-day development |
| Profile (flutter run --profile) | AOT | ❌ | off | Performance testing with DevTools |
| Release (flutter build) | AOT | ❌ | off | Shipping to users |
Profile mode is the unsung hero. It's AOT (so timings are real) but keeps just enough tooling hooks for DevTools' timeline and CPU profiler. When someone says "my app janks," the answer is: reproduce it in profile mode and open the performance overlay. We'll live in profile mode in Part 6.
A subtle but important nuance: this is about Dart, the web aside
Everything above is the native story (mobile/desktop). For the web, Dart doesn't JIT or AOT to machine code — it compiles to JavaScript (dart compile js) or WebAssembly (dart compile wasm), and the browser's own JS/Wasm engine handles execution from there. Same kernel front end (Part 1), different backend.
dart compile js bin/main.dart # → JavaScript for the browser
dart compile wasm bin/main.dart # → WebAssembly (WasmGC)
So the precise statement is: on native targets, Dart uses JIT for dev and AOT for release; on the web, it compiles to JS/Wasm. Keep the target in mind when reasoning about performance.
Practice Challenges
Challenge 1 — Pin the cause. A junior dev reports: "Hot reload stopped working!" Give the first two things you'd check.
Show solution
(1) Are they in a release or profile build? Hot reload only exists in debug (JIT). (2) Did they change something that requires a hot restart instead (e.g. main(), global/static initializers, enum values, or app-level state shape)? Some changes can't be hot-reloaded and need a restart.
Challenge 2 — Explain the slowdown. Your colleague benchmarks a sorting routine in flutter run and concludes Dart is slow. What's wrong with the methodology?
Show solution
They benchmarked in debug mode, which is JIT with assertions on and optimizations effectively off (plus warm-up). Real performance must be measured in profile or release (AOT) mode. Debug timings can be many times slower and are not representative.
Challenge 3 — Tree shaking. Will helperB end up in the release binary? Why?
void helperA() => print('A');
void helperB() => print('B');
void main() => helperA();
Show solution
No. helperB is never reachable from main(), so AOT tree-shaking removes it from the release build. helperA is kept because main() calls it.
Challenge 4 — iOS reasoning. Explain, in terms of how JIT works, why Apple's App Store rules force Flutter to ship AOT on iOS.
Show solution
A JIT generates and executes machine code at runtime, which requires memory that is both writable and executable. iOS App Store policy prohibits that for security reasons. AOT compiles everything to native code before shipping — there's no runtime code generation — so it complies. (This is also why a Flutter debug build on iOS uses special entitlements and isn't allowed on the Store.)
Challenge 5 — Pick the mode. For each goal, name the Flutter build mode: (a) iterate on UI quickly, (b) measure real frame times with DevTools, (c) submit to the Play Store.
Show solution
(a) Debug (flutter run) — JIT + hot reload. (b) Profile (flutter run --profile) — AOT timings with DevTools hooks. (c) Release (flutter build appbundle) — AOT, optimized, no debug overhead.
Questions to test yourself
Q1 (basic). State the one-line difference between JIT and AOT.
Show answer
JIT compiles while the program runs; AOT compiles before the program runs. Everything else (hot reload, startup, size) follows from that timing difference.
Q2 (basic). Which compiler enables hot reload, and which Flutter build modes use it?
Show answer
JIT enables hot reload, and only the debug build (flutter run) uses JIT. Profile and release are AOT and have no hot reload.
Q3 (intermediate). What is "warm-up" in a JIT, and why doesn't AOT have it?
Show answer
Warm-up is the period where JIT-run code is still interpreted/baseline-compiled and being profiled before the optimizing compiler kicks in — so early execution is slower. AOT has no warm-up because all code is already compiled to optimized machine code before the program starts.
Q4 (intermediate). What is tree shaking, and which compiler does it, and why can it do it?
Show answer
Tree shaking is dead-code elimination — dropping any function/class/field not reachable from main(). AOT does it because it has the whole program available statically before running, so it can prove what's unreachable. (JIT can't, since it compiles lazily and code can be added at runtime.)
Q5 (advanced). Why might the optimizing JIT produce faster code for a hot function than AOT does for the same function?
Show answer
The JIT compiles after observing real runtime types and call frequencies, so it can speculatively specialize (assume concrete types, inline the hot path, devirtualize) and deoptimize if wrong. AOT compiles before execution with no runtime observations, so it must stay conservative. For steady-state hot code, profile-guided specialization can beat static compilation — though AOT still wins on startup and predictability.
Q6 (advanced). Beyond startup speed, give a platform-level reason Flutter cannot ship a JIT to iOS users.
Show answer
iOS prohibits runtime code generation (writable+executable memory) for App Store apps. A JIT fundamentally requires generating machine code at runtime, so it's disallowed. AOT produces fixed native code ahead of time and complies with the policy — hence release Flutter apps on iOS are always AOT.
Wrapping up
- JIT compiles during execution; AOT compiles before — that one timing difference drives every other trade-off.
- JIT (debug): adaptive, profiles + specializes hot code, can deoptimize, and uniquely enables hot reload — at the cost of warm-up and a runtime compiler.
- AOT (release): everything compiled up front into a snapshot with tree shaking and whole-program analysis — fast startup, predictable, no hot reload, bigger binary.
- Flutter's three modes: debug = JIT, profile = AOT (+ DevTools), release = AOT. Always profile in profile/release, never debug.
- iOS policy + startup latency are why we ship AOT even though JIT can be fast.
In Part 3 we go inside the runtime that both engines depend on and tackle the thing that quietly governs your app's smoothness: garbage collection and memory management — why Dart's generational GC is tuned for Flutter's allocate-a-million-widgets style.