The Dart Concepts That Separate Good Flutter Developers From Great Ones
The event loop, const canonicalisation, sealed classes, records and extension types, the language features that change how you architect a Flutter app, not just how you write it.
Most Flutter developers learn exactly as much Dart as Flutter forces on them. That gets you a long way, because Flutter's API is well designed and forgiving. It also produces a ceiling that is easy to mistake for the framework's fault.
The developers who get past that ceiling are not the ones who memorised more
widgets. They are the ones who understand what the language is actually doing,
because a surprising amount of Flutter's design is a direct consequence of Dart's
semantics. const constructors, setState, isolates, the whole async story:
these are not framework inventions, they are the framework leaning on the
language.
Here is what is worth understanding properly, in rough order of how much it will change your code.
Glossary
Everything this post uses, defined before it is used. Skip it if the terms are already familiar, or come back when one of them trips you up.
The one sentence the rest of this post expands: Dart runs your code on one
thread, treats identical const values as literally the same object, and gives
the compiler enough information to check your data models for you.
Terms
| Term | Meaning |
|---|---|
| Thread | One line of execution. Dart gives you a single one, so two pieces of your code never run at the same instant. |
| Isolate | Dart's unit of real parallelism: its own memory and its own event loop, talking to others only by copied messages. |
| Event loop | The loop that picks the next piece of pending work and runs it to completion. |
| Microtask | Small work queued by a completed Future, run before any event. |
| Event queue | Where timers, network results, gestures and frames wait their turn. |
async and await | Syntax for pausing a function while it waits for something, without stopping the thread. |
| Future | A single value that arrives later. |
| Stream | Any number of values arriving over time. |
| CPU-bound | Work limited by raw computation rather than by waiting for something. |
| Canonicalisation | Reusing one shared instance for every identical const value. |
| Identity | Whether two references point at the same object, as opposed to two equal objects. |
| Sealed class | A class the compiler knows every subtype of, which makes a switch over it exhaustive. |
| Exhaustive | Every possible case is handled. Missing one becomes a compile error. |
| Pattern matching | Testing a value's shape and pulling fields out of it in one expression. |
| Record | An anonymous, immutable group of values compared by value rather than identity. |
| Extension type | A compile time wrapper around another type that costs nothing at runtime. |
| Mixin | A reusable block of behaviour mixed into a class with with. |
| Factory constructor | A constructor allowed to return an existing or different instance instead of building a fresh one. |
| Tree shaking | Dropping code the compiler can prove is never reached, to shrink the app. |
| Code generation | Producing Dart source from other Dart source at build time, usually to replace runtime reflection. |
Abbreviations
| Short | Full form | In plain words |
|---|---|---|
| UI | User Interface | Everything the user sees and touches |
| CPU | Central Processing Unit | The chip that runs your Dart code |
| I/O | Input and Output | Reading and writing outside your program: network, disk, database |
| API | Application Programming Interface | The set of calls one piece of code offers to another |
| JSON | JavaScript Object Notation | A common text format for data sent over a network |
| ID | Identifier | A value that names one specific thing, like a user or an order |
| ms | milliseconds | A thousandth of a second. A 60fps frame budget is 16.6ms |
| FAQ | Frequently Asked Questions | The question section near the end |
The execution model
Almost every "why did my UI freeze" question comes down to one misunderstanding about how Dart runs code.
There is one thread, and it is yours to block
Dart code runs on a single thread within an isolate. There is no preemption. If your function takes 400ms, nothing else happens for 400ms, including the frame Flutter wanted to draw.
async does not change this. An async function runs synchronously until it
hits an await, then yields control and resumes later. It never runs in
parallel with your other code.
That distinction catches people out, so it is worth seeing. The function below
is marked async and still freezes the app solid, because nothing in it ever
waits.
Future<void> notActuallyBackground() async {
// This blocks the UI for the full loop. `async` bought you nothing.
var total = 0;
for (var i = 0; i < 1000000000; i++) {
total += i;
}
}This is the single most common performance misunderstanding in Flutter. async
is about waiting without blocking, not about working without blocking. If the
work is CPU-bound, await cannot help you, because there is nothing to wait for.
Going deeper: microtasks run before events, and that ordering matters
Think of the event loop as a desk with two in-trays. One tray is marked urgent and must be completely empty before a single item is taken from the other. That is the whole model, and the consequences below all follow from it.
The event loop drains two queues. The microtask queue holds continuations from completed futures, and it is emptied completely before a single event is taken from the event queue. Events are everything else: timers, I/O, gestures, frames.
The practical consequence is that a microtask that schedules another microtask can starve the event queue indefinitely. Your app stops responding while looking completely idle in a CPU profile that only samples periodically.
scheduleMicrotask is almost never what you want. Future(() {}) puts work on
the event queue, where it takes its turn.
Going deeper: isolates are the actual answer for CPU work
If async is one person handling many jobs by never sitting idle, an isolate is
a second person in a separate room. They cannot reach into each other's desks,
so anything one sends the other has to be photocopied and posted.
Isolates are Dart's answer to real parallelism: separate memory, separate event loop, no shared state. They communicate by copying messages, which is why they cannot deadlock the way threads can.
For one-off work, Isolate.run is the whole API:
final result = await Isolate.run(() => expensiveParse(jsonString));The cost is the copy. Sending a large object graph across isolates serialises it, and for some workloads the copy costs more than the computation saved. Measure before assuming an isolate is faster.
Immutability and identity
Flutter rebuilds widgets constantly and compares them cheaply. Both of those depend on Dart features that most tutorials mention in passing.
const is canonicalisation, not just "cannot change"
A const constructor invocation with identical arguments produces the same
instance, every time, for the lifetime of the program. Not an equal instance.
The same one.
const a = SizedBox(height: 8);
const b = SizedBox(height: 8);
assert(identical(a, b)); // trueThat identity is what makes Flutter's rebuild check cheap. When the framework diffs a new widget against an existing element, an identical instance means nothing changed, so the entire subtree is skipped without comparing a single field.
This is why const on widgets is not stylistic. It converts an O(subtree)
comparison into a pointer check.
final is not const, and the difference is when
final means assigned once, at runtime. const means known at compile time.
A final list can still have items added to it. Only the reference is fixed.
final items = <int>[1, 2];
items.add(3); // fine - the reference did not change
const frozen = <int>[1, 2];
frozen.add(3); // runtime error - the list itself is immutableGoing deeper: == and hashCode decide whether your state updates
== is the question "are these two values the same?" and hashCode is a number
Dart uses to file an object into a set or a map. Dart lets you answer both
questions yourself, and the answers you give decide when your screen redraws.
Any state management that skips rebuilds when state is "unchanged" is calling
==. The default implementation is identity, so two separately-constructed
objects with identical fields are not equal, and your UI rebuilds when it did not
need to. Or worse: you mutate an object in place, its identity is unchanged, and
your UI does not rebuild when it should.
Override both together, always. A class that overrides == without hashCode
will behave incorrectly in sets and maps, and the bug will surface far from the
cause.
Modelling data properly
Dart 3 changed what idiomatic modelling looks like, and a lot of Flutter code still reads like Dart 2.
Sealed classes turn state into something the compiler checks
A sealed class can only be extended within its own library, so the compiler knows
every subtype. That makes a switch exhaustive: leave a case out and it does
not compile.
Put plainly: instead of a screen holding a loading flag, a list and an error field that can all be set at once in nonsense combinations, you say the screen is in exactly one of three named states. The compiler then refuses to let you forget one.
sealed class LoadState {}
class Loading extends LoadState {}
class Loaded extends LoadState {
const Loaded(this.items);
final List<Item> items;
}
class Failed extends LoadState {
const Failed(this.error);
final Object error;
}
Widget build(BuildContext context) => switch (state) {
Loading() => const CircularProgressIndicator(),
Loaded(items: final items) => ItemList(items),
Failed(error: final e) => ErrorView(e),
};Compare that to the usual approach: a class with isLoading, items and error
fields, where isLoading == true with a non-null error is representable but
meaningless. Sealed classes make the illegal states unrepresentable, and adding a
fourth state produces compile errors at exactly the places that need updating.
This is the single highest-leverage change most Flutter codebases could make.
Records for the things that do not deserve a class
Records are anonymous, immutable, structurally typed aggregates. They are for returning two values without inventing a class you will use once.
({double lat, double lng}) currentLocation() => (lat: 27.7, lng: 85.3);
final loc = currentLocation();
print(loc.lat);Structural typing means two records with the same shape are the same type, and equality is by value, which makes them useful as map keys.
The judgement call: if the pair has a name in your domain, it deserves a class. A
record called (String, String) at the boundary of an API is a small future
mystery.
Pattern matching is destructuring plus control flow
Patterns work in switch, in if-case, and in assignment. The most useful daily
form is the guard:
if (response case Success(data: final items) when items.isNotEmpty) {
return ItemList(items);
}That checks the type, extracts the field, and tests a condition, in one expression with no casts.
Extension types for zero-cost domain types
An extension type wraps another type with no runtime allocation. It is a compile time view. Think of it as putting a label on a string that only the compiler can read. At runtime it is still just a string, but while you are writing code the label stops you handing the wrong one over.
extension type UserId(String value) {}
extension type OrderId(String value) {}
void fetchUser(UserId id) {}
// fetchUser(OrderId("abc")); // compile errorBoth are String at runtime, so there is no cost. But you cannot pass an order
ID where a user ID is expected, which is a class of bug that shows up in
production far more often than it should.
Everything else that pays for itself
Streams, and knowing which kind you have
A single-subscription stream allows one listener, ever. A broadcast stream allows many. Listening twice to the first throws, and this is the most common Dart runtime error in Flutter apps that use streams directly.
async* generators produce streams lazily, which means the work does not start
until something listens, and stops when the listener cancels. That laziness is
useful and occasionally surprising.
late is a promise, and promises get broken
late tells the compiler you will assign before reading. If you are wrong, you
get a runtime error rather than a compile error, which means you have traded a
guarantee for convenience.
It is right for a field genuinely initialised in initState and read only after.
It is wrong as a way to silence the analyzer.
Mixins compose behaviour, and their order matters
Mixins are how Flutter's framework is assembled, and with A, B applies them
left to right, so B overrides A. on constrains a mixin to types that
provide what it needs:
mixin Logging on State {
void log(String message) => debugPrint('$runtimeType: $message');
}Factory constructors return, they do not construct
A factory can return a cached instance, a subtype, or something built by other
means. Future and List are factories. It is the right tool for parsing, where
the constructor may fail or produce a specialised subtype.
Going deeper: const collections and tree shaking
Your release build should not ship code nobody calls. Removing it is called tree shaking, and it only works when the compiler can follow every call by reading the source.
Dart compiles ahead of time and shakes out unreachable code, but only what it can
prove is unreachable. Reflection-like patterns and dynamic dispatch defeat it.
This is why dart:mirrors does not exist in Flutter and why code generation is
so common in the ecosystem: the compiler needs to see the code to keep it, and
see it unused to drop it.
Key takeaways
- One thread, no preemption.
asyncavoids blocking on waiting, not on working. CPU-bound work needsIsolate.run. - Microtasks starve the event queue. Prefer
Future(() {})overscheduleMicrotaskunless you specifically need to run before the next event. constcanonicalises, so identical const widgets are the same instance, which is what makes Flutter's rebuild skip cheap.finalfixes the reference.constfreezes the value.- Override
==andhashCodetogether, or sets and maps will misbehave far from the cause. - Sealed classes make illegal states unrepresentable and turn missing cases into compile errors. The highest-value change in most codebases.
- Records for anonymous pairs. Classes for anything with a domain name.
- Extension types give you type safety at zero runtime cost, which is how you stop passing the wrong ID.
- Know your stream type. Listening twice to a single-subscription stream is a runtime error.
lateswaps a compile time guarantee for a runtime one. Use it deliberately.
FAQ
Do I need isolates for network calls?
No. Network I/O is already asynchronous and does not block the thread. Isolates are for CPU work: parsing very large payloads, image processing, cryptography.
Is const really worth the effort everywhere?
On widgets, yes, it is the cheapest performance work available, and the linter
can add them for you. Elsewhere it matters less. Turn on
prefer_const_constructors and stop thinking about it.
Should I use records instead of small classes?
For genuinely anonymous, local aggregates, yes. Once a shape appears in more than one place or carries domain meaning, make it a class, you will want a name, a constructor and methods soon enough.
Are sealed classes worth adopting in an existing codebase?
Convert state models first, especially anything currently expressed as a bag of nullable fields plus booleans. That is where exhaustiveness pays immediately. You do not have to convert everything.
What is the difference between Future and Stream?
A Future is one value that arrives later. A Stream is any number, over time.
If you find yourself with a Future<List<T>> that you rebuild repeatedly, you may
actually want a stream.
Do extension types replace typedef?
They solve a different problem. A typedef is an alias and is interchangeable
with the underlying type. An extension type is a distinct type the compiler
enforces, which is the whole point.
Conclusion
The pattern across all of this is that Flutter's design decisions are downstream
of Dart's. const widgets are fast because of canonicalisation. setState
works because rebuilds are cheap, and rebuilds are cheap because widgets are
immutable descriptions. Isolates exist because there is one thread, and there is
one thread because that removes an entire category of concurrency bugs.
Learn the framework and you can build the app. Learn the language underneath it and you start predicting the framework's behaviour instead of discovering it, which is most of what separates someone who has shipped Flutter for a year from someone who has shipped it for five.
The fastest return is the smallest change: turn on the const lints, and convert one state model to a sealed class. Both take an afternoon and both change how the rest of the codebase gets written.
References
Official documentation for the topics covered here.
- Dart: Concurrency - the single thread, the event loop, and where isolates come in
- Dart: Asynchronous programming - futures, streams,
asyncandawait, and what actually yields - Dart: Class modifiers -
sealed,finalandbase, and how sealed types make a switch exhaustive - Dart: Patterns - pattern matching and destructuring, including exhaustive switches over sealed types
- Dart: Records - records as anonymous value-compared tuples, and when they beat a class
- Dart: Extension types - zero-cost compile time wrappers around an existing type
- Dart: Effective Dart - the style and design guidance behind most of these choices
Read more
How Flutter Rendering Actually Works explains why immutable widgets and cheap rebuilds work the way they do, and Flutter Isolates Explained goes deeper on getting work off the UI thread.