Three languages. Two keywords. At least four different meanings.
In JavaScript, const means you can't reassign a variable. In C++, const means the same thing. In Dart, const means something entirely different — and Dart has final for what JavaScript and C++ call const.
If you've ever felt confused by this, you're not confused because you don't understand the concept. You're confused because the same word means different things depending on which language you're writing. The terminology is genuinely bad. Let's fix the mental model.
The Binding and The Thing
Before we touch any keyword, we need one concept: the difference between a variable and the value it holds.
A variable is a name. A label. A tag you attach to a piece of data so you can refer to it later. The data itself — the number, the string, the list, the object — exists independently. The variable just points to it.
variable value
┌──────┐ ┌────────────┐
│ user ├───────→│ { name: │
└──────┘ │ "Alice", │
│ age: 30 }│
└────────────┘When someone says "don't change this," there are two completely different things they could mean:
- Don't move the label. The variable
usermust always point to this specific object. You can't reassign it. But the object itself? Do whatever you want with it.
- Don't change the thing. The object itself is frozen. Its data is fixed. It doesn't matter how many labels point to it — the data cannot be modified, ever, by anyone.
Every piece of confusion about const and final comes from conflating these two. Every single one. Once you see the distinction, the keywords sort themselves out.
JavaScript's const: Don't Move the Label
const user = { name: "Alice", age: 30 };
// Can you reassign the variable?
user = { name: "Bob", age: 25 }; // TypeError: Assignment to constant variable
// Can you change the object?
user.age = 31; // Works fine
user.email = "alice@example.com"; // Works fineJavaScript's const locks the binding. The variable user is permanently attached to that specific object. You cannot make user point to a different object.
But the object itself is wide open. You can change its properties, add new ones, delete existing ones. The const keyword doesn't touch the object. It only locks the arrow between the name and the thing.
LOCKED UNLOCKED
┌──────┐ ┌────────────┐
│ user ├──── ✕ ────→ │ { name: │ ← you can change this
└──────┘ can't move │ "Alice", │
│ age: 31 }│
└────────────┘This surprises people who expect const to mean "constant." It doesn't, in JavaScript. It means "constant binding."
C++ const: The Same Idea, More Seriously
C++ const does the same thing as JavaScript const at the basic level — it locks the variable:
const int x = 42;
x = 100; // Compiler error: assignment of read-only variable
const std::vector<int> numbers = {1, 2, 3};
numbers = {4, 5, 6}; // Can't reassign
numbers.push_back(4); // Can't mutate either — C++ const is stricter hereWait — that last line. C++ const actually does prevent mutation on most standard library types. That's stricter than JavaScript. In C++, when you declare a const vector, calling any non-const method on it is a compiler error. The const propagates to the object's interface.
But this strictness has limits. A const pointer to a mutable object still allows mutation through other, non-const paths to the same data. C++ const is a promise at the variable level, not a deep freeze of the entire object graph. If another non-const pointer exists to the same memory, that pointer can still mutate the data freely.
For the purpose of this article, the core takeaway is: C++ `const` ≈ JavaScript `const` ≈ "lock the binding." The details differ (C++ is stricter about mutation through the const reference), but the fundamental concept is the same — the variable can't be reassigned.
Dart's final: The Equivalent
Here's where Dart enters. Dart's equivalent of JavaScript const and C++ const is the keyword final:
final user = User(name: 'Alice', age: 30);
// Can you reassign the variable?
user = User(name: 'Bob', age: 25); // Compile error: can't assign to final variable
// Can you change the object? (if User has mutable fields)
user.age = 31; // Works if age is not finalSame concept, different keyword. final in Dart means "this variable gets assigned once, at runtime, and never again." The value can be anything — a function return value, user input, a network response, something computed from other variables. It just has to be assigned exactly once.
final now = DateTime.now(); // Assigned at runtime
final response = await http.get(url); // Assigned at runtime
final total = price * quantity; // Computed at runtimeNone of these values are known at compile time. They're determined while the program runs. final doesn't care — it only locks the binding after the first assignment.
The equivalence table so far:
| Concept | JavaScript | C++ | Dart |
|---------|-----------|-----|------|
| Lock the binding (runtime) | `const` | `const` | `final` |Three keywords. One concept. That's the source of the confusion — and now you see through it.
Dart's const: Something Else Entirely
Dart has const too. But it does not mean what const means in JavaScript or C++.
Dart's const means compile-time constant. The value must be fully known before the program runs. Not when the function executes. Not when the widget builds. Before any code runs at all — when the compiler is producing the binary.
const pi = 3.14159; // Known at compile time
const greeting = 'Hello, World!'; // Known at compile time
const maxRetries = 3; // Known at compile time
const now = DateTime.now(); // Compile error — DateTime.now() runs at runtime
const response = await http.get(url); // Compile error — network call is runtimeThe compiler evaluates const expressions and bakes the results directly into the compiled binary. When your program starts, these values already exist. No initialization code runs. No constructor is called. The value is simply there, embedded in the program's data segment.
This has a profound consequence: canonicalization.
One Object, Not Fifty
When you write const in Dart, the runtime guarantees that only one instance of that value exists in memory. Ever.
const a = User(name: 'Alice', age: 30);
const b = User(name: 'Alice', age: 30);
print(identical(a, b)); // true — same object in memoryidentical() checks whether two variables point to the exact same object — not equal values, the same memory address. For const objects, this is always true when the values match. The compiler sees two identical const expressions, produces one object, and makes both variables point to it.
This is not how final works:
final a = User(name: 'Alice', age: 30);
final b = User(name: 'Alice', age: 30);
print(identical(a, b)); // false — two separate objects with equal valuesTwo final variables, two objects in memory. They're equal (if == is implemented correctly), but they're not the same object. Two separate allocations. Two entries for the garbage collector to track.
With const: one object, zero runtime allocation, zero GC pressure. With final: one object per usage site, each allocated at runtime, each tracked by the GC until it goes out of scope.
Multiply this by a widget tree with hundreds of nodes, rebuilding sixty times per second, and the difference is not theoretical.
What const Means in Flutter
This is where it stops being a language trivia question and starts being an engineering decision.
When Flutter rebuilds a widget tree — after setState(), after a BLoC emission, after any state change — it walks the tree and compares each widget to its previous version. If the widget is the same, Flutter skips rebuilding its subtree.
A const widget is always the same. Always. There is only one instance, and identical() returns true every time. Flutter sees "same object, skip everything underneath."
// Rebuilt every time the parent rebuilds — new object each time
child: Text('Welcome back'),
// Created once, at compile time. Flutter skips this on every rebuild.
child: const Text('Welcome back'),The non-const version creates a new Text object on every build call. Flutter has to compare it to the previous one, diff the properties, and decide whether to update the render object. For a simple Text widget, that diff is cheap. For a const Column containing ten const children? The entire subtree is skipped in a single pointer comparison. No diffing. No property checks. One identical() call and Flutter moves on.
This is why the Dart linter suggests adding const wherever possible. It's not a style preference. It's not pedantry. It's telling the framework: "This subtree is frozen. Don't watch it. Don't diff it. Don't rebuild it. Move on."
The Updated Table
| Concept | JavaScript | C++ | Dart |
|---------|-----------|-----|------|
| Lock the binding (runtime) | const | const | final |
| Compile-time constant | — | constexpr | const |
JavaScript has no equivalent of Dart's const. There's no way to tell the JS runtime "this value is a compile-time constant, canonicalize it." The closest concept is Object.freeze(), but that's a runtime operation that prevents mutation — it doesn't enable compile-time evaluation or canonicalization.
C++ does have an equivalent: constexpr. A constexpr value is evaluated at compile time and embedded in the binary, just like Dart's const. But constexpr is used far less commonly in everyday C++ than const is in everyday Dart, partly because C++ doesn't have a framework like Flutter that rewards you for using it.
The Whole Picture
Here's every combination, in one place:
// DART — four combinations
var x = 42; // mutable binding, mutable value (if the type allows)
final x = 42; // locked binding, value determined at runtime
const x = 42; // locked binding, value determined at compile time, canonicalized
final list = [1, 2, 3]; // can't reassign `list`, CAN mutate the list
const list = [1, 2, 3]; // can't reassign `list`, CAN'T mutate the list
// (const collections are unmodifiable)// JAVASCRIPT — two options
let x = 42; // mutable binding
const x = 42; // locked binding, value can be anything (runtime)
// no compile-time constant concept// C++ — two relevant options
int x = 42; // mutable
const int x = 42; // locked binding (runtime initialization allowed)
constexpr int x = 42; // compile-time constant (must be evaluable at compile time)Notice something about Dart's const list: the list itself is also unmodifiable. Calling .add() on a const list throws at runtime. This is because const in Dart means the entire value is frozen — not just the binding, but the object and everything inside it. This is deeper than final, which only locks the binding.
final list = [1, 2, 3];
list.add(4); // Works — final locks the variable, not the list
const list = [1, 2, 3];
list.add(4); // Throws: Unsupported operation: Cannot add to an unmodifiable listThis is the one place where Dart's const goes beyond "compile-time constant" and actually enforces deep immutability. Every object in a const expression must itself be const, recursively. A const list of const objects is frozen all the way down.
Going Deeper: What a Variable Actually Is
Everything above this section is enough to write correct Dart, JavaScript, and C++ code with confidence. What follows is for readers who want to know what's happening underneath — what a "binding" physically is, how the program finds data in memory, and why locking a binding matters at the machine level.
If you're here for the const vs final answer, you have it. If you want to understand why it works the way it does, keep reading.
A Variable Is a Named Address
At the hardware level, there are no variable names. No user, no total, no greeting. There is only memory — a very long array of bytes, each with a numeric address — and the CPU, which reads and writes bytes at specific addresses.
A variable, at the machine level, is a region of memory at a known address. When you write final x = 42, the compiler decides: "x lives at address 1000. It occupies 8 bytes (because it's a 64-bit integer). When the code refers to x, emit an instruction that reads 8 bytes starting at address 1000."
Address Contents What we call it
───────────────────────────────────────────
1000 00 00 00 00 ┐
1004 00 00 00 2A ┘ x = 42 (8 bytes, 0x2A = 42)
1008 48 65 6C 6C ┐
1012 6F 00 00 00 ┘ greeting = "Hello" (null-terminated)The variable name is a compile-time convenience. By the time the program runs, it's gone — replaced by addresses and offsets. (This is why a stack trace shows memory addresses alongside function names: the addresses are what the CPU uses; the names are a debugging aid reconstructed from symbol tables.)
If you've read the Dart FFI pointers post, this is the same model: a pointer is a variable whose value is a memory address. final means the address stored in that variable can't change. const means the value at that address was determined before the program started.
How the Program Knows Where Data Ends
A variable tells the CPU where data starts. But how does it know where the data ends?
This turns out to be one of the most consequential design decisions in programming language history, and there are exactly three strategies.
Strategy 1: The type tells you the size.
For primitive types — integers, floating-point numbers, booleans — the size is fixed and known at compile time. An int32 is always 4 bytes. A double is always 8 bytes. A bool is 1 byte (or 1 bit, depending on context). The compiler knows the size from the type declaration, and emits instructions that read exactly that many bytes.
Type Size How the CPU reads it
────────────────────────────────────────────
int8 1 byte Read 1 byte at the address
int32 4 bytes Read 4 bytes starting at the address
int64 8 bytes Read 8 bytes starting at the address
double 8 bytes Read 8 bytes, interpret as IEEE 754 float
bool 1 byte Read 1 byte, 0 = false, nonzero = trueNo scanning. No searching. The type is the length. This is why strongly typed languages can be so fast — the compiler knows exactly how many bytes to read for every operation, and emits tight, predictable machine code.
Strategy 2: Store the length alongside the data.
For variable-length data — strings, lists, arrays — the size isn't known at compile time. A string could be 3 characters or 3 million. The program needs to know the length at runtime.
The modern solution: store the length as a field next to the data. Every Dart String is internally a pointer to the character data plus an integer holding the length. Every Dart List is a pointer to the backing array plus a length. Every Rust Vec, every Go slice, every Java array — same pattern.
Dart String "Hello" in memory:
┌──────────┐
│ length: 5 │
│ data: ───────→ [H] [e] [l] [l] [o]
└──────────┘The program never scans for an ending. It reads the length field first, then reads exactly that many bytes. This is safe, fast, and predictable. You always know the bounds before you touch the data.
Strategy 3: Scan for a terminator.
This is the C approach to strings, and only strings. A C string is a sequence of bytes ending with \0 — the null terminator. The program reads bytes one at a time until it hits a zero byte, and that's the end of the string.
char greeting[] = "Hello";
// In memory:
// [H] [e] [l] [l] [o] [\0]
// 48 65 6C 6C 6F 00This is simple and compact — no separate length field. But it has a cost that shaped decades of software security: if the \0 is missing, or if someone writes past it, the program keeps reading into whatever happens to be next in memory. That's a buffer overflow — the single most exploited class of vulnerability in the history of computing.
Every time you read about a security exploit in C code — from the Morris worm in 1988 to modern CVEs in system libraries — there's a good chance a missing or overwritten null terminator is involved.
This is why every language designed after C uses length-prefixed strings instead. Dart, JavaScript, Rust, Go, Java, Python, Swift — all of them store the length. The null terminator is a historical artifact that modern languages deliberately avoid.
You may have already seen this boundary: when Dart calls a C function that expects a char*, you convert a length-prefixed Dart String into a null-terminated C string using toNativeUtf8(). Two different worlds, two different conventions, and the conversion happens at the FFI boundary.
Why Locking the Binding Matters
Back to const and final. Now that you know a variable is an address, "locking the binding" has a mechanical meaning: the address stored in the variable cannot change.
Why does the compiler care?
Because if a value can't change, the compiler doesn't have to keep checking whether it changed.
Consider this loop:
final threshold = config.maxRetries;
for (var i = 0; i < attempts.length; i++) {
if (attempts[i].count > threshold) {
// ...
}
}The compiler knows threshold is final. It will never be reassigned. So it can:
- Load the value into a CPU register once, before the loop starts
- Use that register on every iteration
- Never re-read from memory
Without final, the compiler has to be conservative. Maybe something inside the loop changes threshold. Maybe an async callback fires and reassigns it. The compiler can't prove it doesn't change, so it might re-read the value from memory on every iteration — and a memory read is 100x slower than a register read.
This is a simplification — modern compilers are clever about proving values don't change even without final — but the principle holds. The more you tell the compiler about what won't change, the more aggressively it can optimize.
For Dart's const, the optimization goes further. The value isn't just locked at runtime — it doesn't exist at runtime. It's embedded in the binary's data segment. No allocation. No initialization code. No garbage collector tracking. The value is already there when the program starts, like text printed on the page before you open the book.
What Gets "Watched" Without const
In Flutter, every widget in the tree is a potential rebuild candidate. When state changes, the framework walks the tree, compares each widget to its previous version, and decides: rebuild or skip?
For non-const widgets, Flutter must:
- Check if the widget's
runtimeTypechanged - Check if the widget's
keychanged - Call
Widget.canUpdate()to compare old and new - If the widget updated, walk its children and repeat
For const widgets, Flutter does:
- Check
identical(oldWidget, newWidget)— is it the same object? - Yes (it's always yes for
const). Skip everything. Move on.
One pointer comparison. That's it. No property diffing, no child walking, no canUpdate(). The entire subtree rooted at a const widget is invisible to the rebuild process.
This is why experienced Flutter developers structure their widget trees to maximize const boundaries. A const widget at the top of a subtree isn't saving one widget rebuild — it's saving every widget underneath it, recursively.
const and Freezed: Where Code Generation Meets Compile-Time Constants
If you've read the Freezed guide, you know that Freezed generates immutable data classes with correct ==, hashCode, copyWith, and toString. What you might not have noticed is the keyword sitting in front of every factory constructor:
@freezed
class User with _$User {
const factory User({
required String name,
required String email,
required int age,
}) = _User;
}That const factory isn't decoration. It means Freezed instances can be compile-time constants — when all arguments are themselves const:
// Compile-time constant — one object, canonicalized, zero runtime allocation
const user = User(name: 'Alice', email: 'alice@example.com', age: 30);
// Runtime instance — allocated, tracked by GC
final user = User(name: userName, email: userEmail, age: userAge);The first version is baked into the binary. The second is constructed at runtime from dynamic values. Both are immutable (you can't change name on either). But only the const version gets canonicalization and zero allocation cost.
This matters most with union types. Consider a Freezed state class:
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = AuthInitial;
const factory AuthState.loading() = AuthLoading;
const factory AuthState.authenticated({
required User user,
required String token,
}) = AuthAuthenticated;
const factory AuthState.error({required String message}) = AuthError;
}AuthState.initial() and AuthState.loading() take no arguments. That means they can always be const. There's only ever one AuthInitial and one AuthLoading in your entire application. Every time your code says const AuthState.initial(), it refers to the same object. Every time your code says const AuthState.loading(), same object.
AuthState.authenticated(user: user, token: token) takes runtime data — it can't be const. But the no-argument variants can, and those are the ones your app creates most frequently: initial state on startup, loading state on every request. Making them const means those high-frequency state objects are free.
Without Freezed, you'd need to write const constructors by hand — which means implementing ==, hashCode, and copyWith by hand, keeping them in sync with your fields by hand, and manually ensuring every field is final. Freezed generates all of this and preserves the const capability. You get code generation and compile-time constants. Not one or the other.
const in BLoC: The Initial State Trap
Here's a pattern that trips up developers who haven't internalized what const means:
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc() : super(AuthState.initial()) {
// ...
}
}This works. But every time an AuthBloc is created, AuthState.initial() allocates a new object. If your app creates the BLoC on every navigation (common in some architectures), that's a new AuthInitial instance each time — equal in value, but a separate allocation tracked by the garbage collector.
Now add const:
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc() : super(const AuthState.initial()) {
// ...
}
}One word. Now every AuthBloc starts with the same AuthInitial object. Not an equal one — the same one. One allocation, total, for the lifetime of the application. The GC never sees it because it lives in the binary's data segment, not on the heap.
The same applies everywhere you emit a no-argument state:
Future<void> _onLogout(LogoutEvent event, Emitter<AuthState> emit) async {
await _authRepository.clearSession();
emit(const AuthState.initial()); // ← same object as the BLoC's initial state
}After logout, the BLoC emits const AuthState.initial(). BLoC compares it to the previous state using ==. If the previous state was also AuthInitial, the comparison short-circuits to identical() — same object, same reference, instant. No field-by-field equality check.
This extends to loading states too:
Future<void> _onSearch(SearchEvent event, Emitter<AuthState> emit) async {
emit(const AuthState.loading()); // ← one object, reused across every search
final result = await _searchUseCase(event.query);
// ...
}Every search emits const AuthState.loading(). Every time, it's the same object. BLoC still emits it (because the previous state was likely authenticated or error, so == returns false and the UI rebuilds). But the allocation cost is zero. For BLoCs that handle rapid user input — search-as-you-type, pagination, pull-to-refresh — this adds up.
The rule: if a Freezed factory constructor takes no arguments (or all arguments have `const`-compatible defaults), prefix every usage with `const`. The Dart analyzer will suggest it. Don't ignore it — for BLoC state objects, it's not a style lint, it's a performance guarantee.
The Decision in Practice
Here's the heuristic:
Use `const` when the value is known at compile time. Literal numbers, literal strings, widget trees with no dynamic data, configuration values that never change. If the Dart analyzer suggests const, add it — it's never wrong to do so.
Use `final` when the value is determined at runtime but shouldn't be reassigned. API responses, computed values, timestamps, objects created from user input. Most local variables in well-written Dart are final.
Use `var` when the value genuinely needs to change — loop counters, accumulators, state that mutates over time. If you reach for var, ask: "Does this actually need to change, or can I restructure to use final?" Often, you can.
The priority is: const if possible, final if not, var if you must. This isn't a style rule — it's an optimization gradient. Each step up gives the compiler more information, the framework more shortcuts, and the garbage collector less work.
const and final are not about restricting what you can do. They're about telling the system what it doesn't need to worry about. final says "this binding is stable — optimize for that." const says "this value will never change, was never computed, and exists exactly once — optimize for all of that."
In a framework like Flutter, where the runtime rebuilds widget trees sixty times per second and the garbage collector runs between frames, those guarantees compound. A hundred const widgets aren't a hundred small optimizations — they're a hundred subtrees the framework never visits, a hundred allocations that never happen, a hundred objects the GC never tracks.
The keyword is small. The effect isn't.