Ankit Ranjan
Back to Deep Dives

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.

May 24, 2026 7 topics 7 quiz questions
Share:
1

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.

Dart's three execution modesJIT ModeJust-In-TimeCompiles while runningHot reload worksDebug info availableBest for: Developmentdart run app.dartflutter run --debugAOT ModeAhead-Of-TimeCompiles before runningNative machine codeTree shaking appliedBest for: Productiondart compile exeflutter build --releaseKernel ModeIntermediatePlatform-independentBinary AST formatUsed internallyBest for: Toolingdart compile kernelanalyzer, formatterSame language, three different execution strategies. Each optimised for its use case.

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.

2

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.

JIT compilation — the optimisation feedback loopSource Code.dart filesparseKernel IRbinary ASTcompileUnoptimisedmachine coderunExecuteProfile Datahot paths, typesfeedbackOptimisingcompilerOptimised Codeinlined, specialisedreplace hot codeProfile-guided optimisation:- Track which functions run often- Track which types are actually used- Inline hot paths, remove dead codeThe VM learns from runtime behaviour and recompiles hot code with aggressive optimisations.

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.

3

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.

JIT vs AOT — what happens when the app startsJIT Mode (Development)Load VM~200msParse~150msJIT Compile~300msWarm-up~500msPeak Speedreached~1.2 secAOT Mode (Production)Load Binary~20msRun~30msAlready at Peakno warm-up needed~50msThe AOT compilation pipelineDart Source.dart filesKernel IR.dill fileTree Shaking+ optimisationNative Binary.exe / .so / .dylibEverything happens at build time. The runtime just loads and runs pre-compiled code.This is why Flutter release apps feel instant — no compilation overhead at startup.

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.

4

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.

Tree shaking — trace from main(), remove the restBefore tree shakingAfter tree shakingmain()HomeScreenUserServiceButtonTextSliderDatePickerColorPickerChartsImported but never usedmain()HomeScreenUserServiceButtonTextDead code removedBinary size: 12 MB → 4 MBOnly reachable code shipsTree shaking is a whole-program analysis. It requires AOT — JIT can't know what's unused until runtime.

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.

5

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.

Snapshot types — progressively more bakedKernel Snapshot.dill fileBinary AST (no machine code)Platform-independentdart compile kernelApp-JIT Snapshot.jit fileCompiled + profile dataFaster JIT startupdart compile jit-snapshotAOT Snapshot.aot / native binaryNative machine codeNo compiler neededdart compile exeWhat's inside a snapshot?Kernel- Classes, functions- Type hierarchy- ConstantsApp-JIT- Kernel + compiled code- Profile data- Heap objectsAOT- Native code only- Initialised globals- Type metadataEach snapshot level trades flexibility for startup speed.

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.

6

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.

Generational GC — most objects die youngYoung Generation (Nursery)~2 MB, collected frequentlyFrom SpaceTo SpaceScavenger (copying collector)- Copy live objects from → to- Swap spaces, old from-space is now free- Super fast: ~1-2 ms pauseOld GenerationObjects that survived multiple GCsHeap (mark-sweep/mark-compact)Major GC (less frequent)- Mark all reachable objects- Sweep/compact unmarked memory- Longer pause but happens rarelypromote survivorsWhy generational GC is fastTypical Object Lifetime90%+ of objects are temporaryCallbacks, builders, iterators...Young Gen StrategyCollect often, very quicklyOnly copy the few survivorsResultMost GC pauses: 1-2 msSmooth 60 fps in FlutterThe generational hypothesis: most objects die young. Optimise for that case.

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

7

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.

Threads vs Isolates — different concurrency modelsTraditional ThreadsThread 1Thread 2Shared MemoryRace conditions possible!Requires locks, mutexes, careful coordinationDart IsolatesIsolate 1Own heapOwn GCOwn event loopIsolate 2Own heapOwn GCOwn event loopmessageNo shared state. Communication via message passing.Message passing semantics- Messages are copied or transferred (no shared references)- SendPort / ReceivePort for communication- Primitives, strings, and transferable objects are fast; complex objects require serialisationIsolates are safe by design. The cost is serialisation overhead for communication.

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.

Search

Loading search...