Dart: The Platform — VM, Compilation, and Runtime
Dart is not just a language — it is a platform. JIT for development, AOT for production, tree shaking for size, and a generational garbage collector that makes Flutter feel instant.
The Dart runtime landscape
Most programmers think of Dart as a language. But Dart is really a platform — a language, a compiler, a runtime, and a set of tools that work together.
When we write Dart code, something has to run it. In JavaScript, that's V8 or SpiderMonkey. In Java, that's the JVM. In Dart, it's the Dart VM — a virtual machine that executes Dart code and manages memory.
But here's what makes Dart unusual: it doesn't pick just one execution model. Dart has three different ways to run code, and each one is optimised for a different purpose.
JIT (Just-In-Time) compiles code while the program runs. This is what we use during development — it enables hot reload, fast iteration, and rich debugging. The trade-off is startup time and memory: the compiler itself has to be loaded.
AOT (Ahead-Of-Time) compiles everything to native machine code before the program runs. This is what ships in Flutter release builds. No compiler overhead at runtime, instant startup, smaller memory footprint. The trade-off is that hot reload doesn't work — the code is frozen at compile time.
Kernel is an intermediate representation — a platform-independent binary AST. Tools like the analyser and formatter work at this level. Most developers never touch it directly, but it's the glue between the frontend (parsing) and the backends (JIT/AOT).
This three-mode architecture is why Dart can be both a rapid-iteration development language and a high-performance production runtime. Let's see how each mode actually works.
JIT compilation — the development engine
When we run dart run app.dart or flutter run in debug mode, we're using JIT compilation. The Dart VM loads our source code, compiles it to machine code on the fly, and starts executing.
But JIT isn't just "compile and run." The VM is constantly watching how our code behaves, and it uses that information to make the code faster.
How the feedback loop works:
1. The VM starts by compiling functions to unoptimised machine code. This is fast to generate but not especially fast to run.
2. As the code runs, the VM collects profile data: which functions are called often (hot), what types are actually passed to them, which branches are taken.
3. When a function becomes hot enough, the optimising compiler kicks in. It uses the profile data to generate much faster code — inlining function calls, eliminating type checks when the type is always the same, unrolling loops.
4. The optimised code replaces the old code. Future calls to that function run the faster version.
Deoptimisation — when assumptions fail. The optimising compiler makes assumptions based on what it has seen so far. "This function always receives an int." But what if, later, someone passes a String?
The VM handles this gracefully: it deoptimises. It throws away the optimised code and falls back to the unoptimised version, which can handle any type. The profile data updates, and the optimiser might try again with new assumptions.
void process(dynamic value) {
// First 1000 calls: value is always int
// Optimiser assumes int, generates fast int-specific code
// Call 1001: value is String
// Deoptimise! Fall back to generic code
}
Hot reload — the JIT superpower. Because the JIT keeps the VM running and can recompile individual functions, Dart can replace code without restarting the app. This is hot reload — we change a line, save the file, and see the result instantly. The VM patches the running code in place, preserving app state. No other compiled language offers this during development.
AOT compilation — production performance
JIT is brilliant for development, but it carries baggage: the compiler itself must be loaded into memory, startup is slow while code is compiled, and profile-guided optimisation takes time to kick in.
For production, we want the opposite: instant startup, minimal memory, peak performance from the first millisecond. That's AOT.
What AOT gives up:
- No hot reload — code is frozen at compile time
- No runtime reflection (in full AOT mode) — everything must be known statically
- Longer build times — compilation happens once, upfront
What AOT gains:
- Instant startup — no parsing, no JIT compilation
- Smaller memory footprint — no compiler in memory
- Consistent performance — no warm-up jitter
- Smaller binaries — tree shaking removes unused code
# Compile a standalone executable
dart compile exe app.dart -o myapp
# Flutter release build (uses AOT)
flutter build apk --release
flutter build ios --release
This is why the same Dart code feels different in debug vs release mode. Debug mode is JIT — slower to start, but you get hot reload. Release mode is AOT — instant and smooth from the first frame.
Tree shaking — dead code elimination
When we import a package, we rarely use everything in it. Maybe we import all of Flutter, but our app only uses a handful of widgets. In a naive compiler, all that unused code would ship in the final binary.
Tree shaking solves this. The compiler starts from main() and traces every function, class, and variable that is actually reachable. Anything not in that trace gets removed.
Why "tree shaking"? Think of the program as a tree. main() is the root, function calls are branches. We shake the tree; whatever isn't connected falls off.
What survives tree shaking:
- Any code reachable from main()
- Anything called via Function.apply or noSuchMethod
- Code preserved by @pragma('vm:entry-point')
What gets removed:
- Unused classes, functions, methods
- Unused enum values
- Unreachable branches (if the condition is provably constant)
// This entire class gets removed if never instantiated
class DebugHelper {
static void logState() { ... }
static void dumpWidgetTree() { ... }
}
// If this is never called, the whole class is gone
// DebugHelper.logState();
The flip side — what breaks tree shaking:
Dynamic features like
dart:mirrors (reflection) defeat tree shaking because the compiler cannot prove what's unused. This is why reflection is disabled in AOT mode. If we use Function.apply or dynamic heavily, the compiler must keep more code "just in case."
For maximum tree shaking, keep types static and avoid
dynamic where possible.
Snapshots — pre-cooked program state
Compiling Dart source to machine code is expensive. Even with JIT, parsing and initial compilation take time. What if we could skip that work?
That's what snapshots do. A snapshot is a serialised representation of a Dart program at a particular stage of compilation. Instead of recompiling from source, the VM loads the snapshot directly.
Kernel snapshot is the output of the frontend compiler. It's a binary representation of the program's structure — classes, functions, types — but no machine code yet. Tools like the analyser and formatter work at this level. The kernel is platform-independent.
App-JIT snapshot goes further. It includes compiled machine code and profile data from a training run. When the VM loads an app-jit snapshot, it skips parsing and initial compilation, starting with already-warm code. This is useful for server applications that restart frequently.
# Create an app-jit snapshot
dart compile jit-snapshot -o app.jit app.dart
# Run from the snapshot (faster startup)
dart run app.jit
AOT snapshot is the fully baked version. Native machine code for a specific platform, with tree shaking applied. This is what flutter build --release produces. The "runtime" in this case is minimal — just the garbage collector and a thin startup stub.
The progression is: source → kernel → app-jit → AOT. Each step trades flexibility for speed.
The garbage collector — generational and concurrent
Dart allocates objects on the heap. Every new Widget(), every closure, every collection — they all need memory. And eventually, they need to be cleaned up.
The garbage collector (GC) is the system that reclaims memory from objects we're no longer using. Dart uses a generational garbage collector based on a key observation: most objects die young.
The young generation (nursery) is where new objects are allocated. It's small — about 2 MB — and gets collected frequently. The collector uses a scavenger algorithm: copy live objects to a second space, then swap. This is extremely fast because most objects are already dead — we're copying the minority, not scanning the majority.
The old generation holds objects that survived multiple young-gen collections. It's larger and collected less often. The collector uses mark-sweep or mark-compact: mark everything reachable from the roots, then sweep away (or compact) the rest.
Write barriers. When old-gen objects point to young-gen objects, the GC needs to know — otherwise it might collect a young object that's still reachable. Write barriers are small checks inserted into every pointer write. When we assign a reference, the barrier records it so the GC doesn't miss it.
Concurrent and incremental collection. Dart's GC does much of its work concurrently with the main thread. Marking happens on a background thread; only the final "stop-the-world" phase pauses the app, and it's kept short. This is why Flutter can maintain 60 fps even while allocating heavily — the GC is designed for low-latency, not maximum throughput.
// Each frame might create hundreds of temporary objects
Widget build(BuildContext context) {
return Column( // new object
children: [ // new list
Text('Hello'), // new object
Icon(Icons.star), // new object
],
);
}
// All of these are young-gen, collected quickly
Isolate memory model — no shared state
In most languages, threads share memory. This is fast but dangerous — race conditions, deadlocks, data corruption. We need locks, and locks are hard to get right.
Dart takes a different approach: isolates. Each isolate has its own heap, its own garbage collector, its own event loop. Isolates share nothing. They communicate by passing messages.
Why no shared memory? Because it eliminates whole classes of bugs. No data races, no deadlocks from lock contention, no need for synchronisation primitives. The trade-off is that communication has a cost — messages must be copied or serialised.
// Spawn an isolate for heavy computation
void main() async {
final result = await Isolate.run(() {
// This runs in a separate isolate
// with its own heap
return computeHeavyTask();
});
print(result);
}
What can be sent between isolates:
- Primitives (int, double, bool, null)
- Strings
- Lists and Maps of sendable types
- SendPort and ReceivePort
- Transferable objects (TransferableTypedData)
- Custom objects (via serialisation)
What cannot be sent:
- Closures that capture non-sendable state
- Arbitrary objects with references to the heap
For CPU-intensive work — image processing, JSON parsing, cryptography — isolates let us use multiple cores without thread-safety headaches. Flutter's
compute() function is a wrapper around this pattern.
// Flutter's compute() helper
final result = await compute(parseJson, rawJson);
// Equivalent to spawning an isolate manually
// but with automatic serialisation handling
The isolate model is more restrictive than threads, but that restriction is the point. We trade raw flexibility for safety and simplicity. For most app development, this is the right trade.
Test your understanding
7 questions
Seven questions covering Dart's execution modes, compilation, tree shaking, garbage collection, and isolates.