NK

Search

Search pages, projects, posts, components, and icons

All articles
Mobile15 min read

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.

dartflutterlanguagearchitecture

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

TermMeaning
ThreadOne line of execution. Dart gives you a single one, so two pieces of your code never run at the same instant.
IsolateDart's unit of real parallelism: its own memory and its own event loop, talking to others only by copied messages.
Event loopThe loop that picks the next piece of pending work and runs it to completion.
MicrotaskSmall work queued by a completed Future, run before any event.
Event queueWhere timers, network results, gestures and frames wait their turn.
async and awaitSyntax for pausing a function while it waits for something, without stopping the thread.
FutureA single value that arrives later.
StreamAny number of values arriving over time.
CPU-boundWork limited by raw computation rather than by waiting for something.
CanonicalisationReusing one shared instance for every identical const value.
IdentityWhether two references point at the same object, as opposed to two equal objects.
Sealed classA class the compiler knows every subtype of, which makes a switch over it exhaustive.
ExhaustiveEvery possible case is handled. Missing one becomes a compile error.
Pattern matchingTesting a value's shape and pulling fields out of it in one expression.
RecordAn anonymous, immutable group of values compared by value rather than identity.
Extension typeA compile time wrapper around another type that costs nothing at runtime.
MixinA reusable block of behaviour mixed into a class with with.
Factory constructorA constructor allowed to return an existing or different instance instead of building a fresh one.
Tree shakingDropping code the compiler can prove is never reached, to shrink the app.
Code generationProducing Dart source from other Dart source at build time, usually to replace runtime reflection.

Abbreviations

ShortFull formIn plain words
UIUser InterfaceEverything the user sees and touches
CPUCentral Processing UnitThe chip that runs your Dart code
I/OInput and OutputReading and writing outside your program: network, disk, database
APIApplication Programming InterfaceThe set of calls one piece of code offers to another
JSONJavaScript Object NotationA common text format for data sent over a network
IDIdentifierA value that names one specific thing, like a user or an order
msmillisecondsA thousandth of a second. A 60fps frame budget is 16.6ms
FAQFrequently Asked QuestionsThe 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)); // true

That 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 immutable

Going 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 error

Both 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. async avoids blocking on waiting, not on working. CPU-bound work needs Isolate.run.
  • Microtasks starve the event queue. Prefer Future(() {}) over scheduleMicrotask unless you specifically need to run before the next event.
  • const canonicalises, so identical const widgets are the same instance, which is what makes Flutter's rebuild skip cheap.
  • final fixes the reference. const freezes the value.
  • Override == and hashCode together, 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.
  • late swaps 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.

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.