HomeDocumentationc_005_flutter_compile
c_005_flutter_compile
13

Compile Time vs Runtime: The Two Lives of Your Code

Compile Time vs Runtime in Flutter and Dart: A Deep Dive

March 17, 2026

A program lives twice

Every program you write has two distinct existences.

The first life is static — your code sitting in files, being read, analyzed, transformed. No data flows through it. No user has touched it. The CPU hasn't executed a single instruction of it. It's a description of a computation, not the computation itself. This is compile time: the period when tools inspect your program and translate it into something a machine can run.

The second life is dynamic — your program actually executing, encountering data it's never seen before, responding to user input, making network requests, running on a device you've never held. This is runtime: when your program meets reality.

These two lives have completely different properties. Different things can go wrong in each. Different tools help you in each. And one of the most valuable skills you can develop as a developer — in any language, any platform — is learning which problems belong to which life, and pushing as many problems as possible into the earlier one.

Before we get to compile time, there's parse time

Compile time is not actually the first thing that happens to your code. Before the compiler can analyze anything, it needs to understand the shape of what you've written.

Parse time is when the compiler reads your source files and turns raw characters into a structured tree — the AST, or Abstract Syntax Tree. It's purely mechanical: is this text syntactically valid? Do the parentheses balance? Is this a valid expression?

Parse errors look like this:

dart
void greet(String name {   // ← missing closing parenthesis
  print('Hello, $name');
}
javascript
Error: Expected ')' before '{'.

The compiler doesn't know what name is, what String means, or what greet does. It just knows the brackets don't balance. Parse time is the most superficial check — necessary, but not very interesting.

Most modern IDEs catch parse errors before you even save the file. Flutter's language server (the analysis_server process running in the background of your IDE) continuously re-parses your code as you type. By the time you hit Run, parse errors are almost always already gone.

Compile time: where meaning is checked

Once the AST exists, the real work begins. The compiler walks the tree and asks deeper questions: do the types make sense? Are you calling methods that exist? Are you handling every possible case?

This is compile time in the meaningful sense — what most developers mean when they say "compile error."

dart
void sendWelcome(String email) {
  print('Sending to: $email');
}

sendWelcome(42); // ← compile error
javascript
Error: The argument type 'int' can't be assigned to the parameter type 'String'.

The program hasn't run. No email has been sent. The CPU has executed nothing. But the compiler already knows this is wrong, because it can see — statically, by reading the code — that 42 is an int and sendWelcome expects a String. The discrepancy is visible without any data, any input, any execution.

This is the compiler's superpower: it can see all possible executions of your program at once, in the abstract. It doesn't know what data will flow through at runtime — but it can reason about what types of data are possible, and flag mismatches before they happen.

Compile-time errors in Flutter are surfaced in three places: your IDE (instantly, via the language server), flutter analyze (explicitly), and the build itself (when you run flutter build). All three share the same analysis engine. If your code has a type error, it will not compile into an APK. The build fails. No user ever sees that mistake.

The JIT middle ground: when compile time and runtime blur

In the post about Flutter compilation we saw that Flutter has two execution modes — JIT for debug, AOT for release. And JIT introduces a temporal grey zone worth understanding.

In release builds, compile time is a discrete event: you run flutter build apk, the compiler runs, and then it's done. The compiled binary either works or it doesn't.

In debug builds — what you're running when you hit Run in your IDE — compilation is continuous. The Dart VM receives your Kernel IR and compiles individual functions to machine code on demand, as they're called. Functions that get called frequently ("hot" paths) are further optimized by the JIT in the background. Functions that are never called may never be fully compiled at all.

This is why debug builds feel different from release builds beyond just speed:

  • A type error in a dead code path might not surface in debug until something calls that path. In release, the AOT compiler analyzed everything, even code that's never reached.
  • Performance is variable in debug — the JIT is still warming up, profiling, re-optimizing. A screen that renders at 30fps in debug might render at 120fps in release.
  • Hot reload works in JIT because you're swapping bytecode into a running VM. In AOT, you have a binary — you can't patch it mid-flight.

The JIT doesn't change what is a compile-time error vs a runtime error conceptually, but it makes the boundary fuzzier in practice. Another reason to not treat debug performance as representative of release behavior.

Runtime: where reality enters

Compile time knows your program's structure. It knows types, call signatures, import graphs. What it fundamentally cannot know is your data.

What will the user type into the search field? What will the API return? Will the network request succeed? Is the list empty or does it have a thousand items? The compiler has no idea. These questions only have answers when the program is running.

Runtime is when your program meets actual data, and the assumptions baked in at compile time either hold or break.

Some runtime errors in Flutter:

dart
final items = ['apple', 'banana', 'cherry'];
print(items[5]); // compiles fine, crashes at runtime
// RangeError (index): Invalid value: Not in inclusive range 0..2: 5

The compiler sees a List<String> and an int index. That's valid. It cannot know the list has three items and you're asking for index five. That's a data problem, not a type problem.

dart
final data = jsonDecode(response.body) as Map<String, dynamic>;
final price = data['price'] as double;
// Compiles. But if the API returns {"price": "19.99"} (a String),
// this throws: CastError: type 'String' is not a subtype of type 'double'

Again — the compiler blessed this. The types look consistent from the structure of the code. But the API returned something unexpected, and as double is a runtime cast, not a compile-time guarantee.

dart
Navigator.of(context).pushNamed('/product', arguments: productId);
// Compiles perfectly. But if '/product' isn't registered in your router,
// this throws at runtime: Could not find a generator for route RouteSettings("/product")

Route names are strings. The compiler doesn't know your route table — that's assembled at runtime. A typo in a route name is invisible until a user tries to navigate there.

This is the nature of runtime: it's where the assumptions end and the surprises begin.

There are other "times" worth knowing

The compile-time / runtime binary is the most important distinction, but the timeline is a bit richer:

Link time — in traditionally compiled languages (C, C++, Rust), compilation and linking are separate phases. Individual files compile to object files; the linker combines them and resolves references between files. Dart largely abstracts this — the CFE handles the whole program at once — but gen_snapshot's type flow analysis and dead code elimination is conceptually link-time work: it sees across all files simultaneously.

Load time — when the OS loads your APK into memory and before main() is called. Static fields are initialized here. In Dart, top-level final variables with initializers are evaluated here. If a top-level initializer throws — rare, but possible — your app crashes before it ever shows a frame.

JIT warmup time — specific to debug and JIT-mode execution. The period where the VM is profiling your running code and re-optimizing hot paths. Performance is genuinely lower during warmup, which is why benchmark results from the first few seconds of a debug run are meaningless.

Idle time — between user interactions, the Dart VM can run the garbage collector, complete microtasks, and do background work. Understanding that idle time exists is relevant when you're reasoning about when GC pauses might occur (more on this in Post 3).

The developer's most important instinct

Here is the insight at the heart of this post, and arguably the heart of most good software engineering:

Every runtime error that you can move to compile time is a bug your users will never see.

A compile-time error surfaces on your machine, while you're writing code, in milliseconds. A runtime error surfaces on a user's device, in production, under conditions you didn't test. One is free. The other costs trust.

The entire history of programming language design is, in part, a story of moving errors earlier in the timeline. Stronger type systems. Static analysis. Formal verification. Every new feature that lets the compiler catch more mistakes is a feature that reduces what survives to runtime.

Dart has leaned into this hard. And no single example makes the case more clearly than null safety.

Null safety: the most consequential Dart decision

Before Dart 2.12, every type in Dart was implicitly nullable. String name meant "a String, or possibly null." The compiler didn't know which. So it couldn't warn you when you wrote name.length and name might be null at that point.

The result: Null check operator used on a null value — a NullPointerException, one of the most common runtime crashes in any managed language. Invisible at compile time, fatal at runtime.

Dart 2.12 introduced sound null safety. Now:

dart
String name = 'Alice';   // ← provably never null
String? nickname = null; // ← might be null, compiler knows it

print(name.length);      // ← fine
print(nickname.length);  // ← compile error: nullable receiver
javascript
Error: Property 'length' cannot be unconditionally accessed because the receiver can be of type 'Null'.

The compiler now tracks nullability as part of the type. String and String? are genuinely different types. The flow analysis understands null checks:

dart
if (nickname != null) {
  print(nickname.length); // ← fine: compiler proved it's non-null here
}

An entire category of runtime crash — one that plagued Dart, Java, Kotlin (pre-null-safety), Swift (pre-optionals), and every other managed language — became a compile-time error. Users never see it. The compiler catches it before the binary is built.

Flutter projects that migrated to null safety saw measurable drops in crash rates. Not because the logic changed — because the compiler started enforcing a contract that was always intended but never verified.

Sealed classes: the newer frontier

Dart 3 introduced sealed classes, and they represent the same instinct applied to a different problem: exhaustiveness.

Imagine you have a network request that can be in one of several states:

dart
sealed class RequestState {
  const RequestState();
}

class Loading extends RequestState {}
class Success extends RequestState { final String data; const Success(this.data); }
class Failure extends RequestState { final String message; const Failure(this.message); }

Now when you switch on this type:

dart
Widget buildContent(RequestState state) {
  return switch (state) {
    Loading() => const CircularProgressIndicator(),
    Success(:final data) => Text(data),
    // ← compile error if you forget Failure
  };
}
javascript
Error: The type 'RequestState' is not exhaustively matched by the switch cases
since it doesn't match 'Failure'.

You cannot ship a build where you forgot to handle the error case. The compiler won't allow it. Before sealed classes, forgetting a state meant a silent bug — maybe a blank screen, maybe a crash, discovered by a user at runtime. Now it's a compile error, discovered by you in your IDE.

The pattern extends to anything with discrete states: authentication (Unauthenticated, Authenticated, Verifying), payment flows, onboarding steps. Wherever you have a closed set of possibilities, sealed makes omitting one a compile-time error.

`const` in Flutter: computation at compile time

There's a lighter version of this timeline-shifting in Flutter that you've probably used without thinking much about: the const keyword.

dart
// computed at compile time — the object exists before main() runs
const padding = EdgeInsets.all(16.0);
const style = TextStyle(fontSize: 18, fontWeight: FontWeight.bold);

// these two are literally the same object in memory
const a = Text('Hello');
const b = Text('Hello');
assert(identical(a, b)); // true

const widgets and values are computed during compilation and stored in the binary's read-only data segment. At runtime, Flutter doesn't construct them — it just reads them from memory. They're never garbage collected. They're never rebuilt unnecessarily.

When Flutter's build() method is called during a frame rebuild, const subtrees are skipped entirely — the framework knows they can't have changed, because they're compile-time constants. This is one of the cheapest performance optimizations available in Flutter, and it's free: you just add const where it's valid, and the compiler does the rest.

Where DDD fits on this timeline

If you've been following the DDD series on this blog, you've seen the same instinct from a different angle.

A Money value object that validates its amount in its constructor — can't be negative, can't have more than two decimal places — is doing something similar to null safety. It's not a compile-time check; the validation runs at object construction. But it moves the failure from "somewhere in the middle of your business logic when an invariant is violated" to "at the boundary where the object is created," which is earlier, louder, and more debuggable.

An Email value object that either represents a valid email address or cannot be instantiated at all is making invalid states unrepresentable — the same principle behind sealed classes and null safety, applied at the domain level rather than the language level.

The timeline of when errors are caught:

javascript
compile time → object construction → business logic → user action → production
←─────────────────── cheaper to catch ──────────────────────────────────────►

The further right an error surfaces, the more expensive it is — more users affected, more debugging context lost, more trust eroded. DDD, null safety, sealed classes, type systems, and const widgets are all strategies for shifting errors to the left.

What compile time can never catch

With all of this, it's worth being honest about the frontier that compile-time tools cannot reach.

The compiler cannot know what your API will return. It cannot know whether the user's device has enough memory. It cannot know whether a network request will succeed or time out. It cannot know whether an index will be in bounds — not without tracking the possible sizes of every collection at every point in the program (a computationally intractable problem for real-world programs).

These are genuinely runtime concerns. And for them, you need different tools: defensive coding, error handling, observability, and profiling.

One category of runtime error deserves special attention because it's invisible — no exception thrown, no crash, no error log. It accumulates slowly and expresses itself as sluggish behavior or an out-of-memory kill from the OS:

Memory leaks.

A GC-managed language like Dart handles memory automatically — you don't call free(), objects are cleaned up when they're no longer reachable. But "no longer reachable" is determined by the reference graph, not by your intentions. If you forget to cancel a StreamSubscription, the stream holds a reference to your callback, your callback holds a reference to your widget, and the GC correctly keeps everything alive — forever.

The compiler can't see this. No type system catches it. It's a runtime problem, silent and cumulative.

That's where Post 3 begins.

The thread running through this series

The Flutter compilation overview showed you what your code becomes: a compiled binary, two shared libraries in a ZIP file, executed natively on silicon.

This post showed you the timeline of that transformation — when different kinds of errors surface, and why moving them earlier is always the goal.

Our Article about Dart Garbage Collector will show you the one class of runtime error that no compiler, no type system, and no null safety annotation can save you from — and what you can do about it instead.

Related Topics

compile time vs runtime dartflutter compile time errorsdart null safety explaineddart aot jit differenceflutter const widgets compile timeflutter error types

Ready to build your app?

Turn your ideas into reality with our expert mobile app development services.