An isolate is an independent Dart worker with its own memory and its own event loop, so it runs truly in parallel with the UI isolate. Move work into one when it is CPU-heavy and would take longer than a frame (about 16 ms) on the main isolate.
No shared state: isolates cannot see each other's objects, so there are no locks; they talk only by messages, which are copied (immutable values such as strings may be shared)
**Isolate.run()**: one function in, one result out; the default choice. Flutter's compute() does the same job.
**Isolate.spawn() with a ReceivePort: a long-lived worker that handles many jobs
Good fits: decoding a large JSON, image processing, hashing, compression
Bad fits: waiting for network or disk (that is already async and blocks nothing), tiny tasks where the copy costs more than the work, anything that touches widgets
Web**: there are no isolates on the web; compute() runs the function on the same thread
FlutterMiddle
Advanced Dart
Generics, extensions, mixins, isolates, dart:core advanced
Questions closed
0 / 15
- 0Review
- 3Asked often
Questions
late lets a non-nullable variable be assigned after its declaration. The compiler stops checking that it is set, and reading it before the first assignment throws LateInitializationError at run time.
Deferred init: late String _draft; is assigned in initState, so a State field stays non-nullable without a dummy value
Lazy init: late final _anim = AnimationController(vsync: this); runs the initializer on the first read, which is also why it may use this
Already lazy: top-level and static variables are initialized on first access anyway; late adds nothing there
Wrong when the value may be absent: that is T?; late only turns the null check into a crash
Wrong for async results: the gap before the data arrives is a real state; model loading and error states instead
Nothing matched. firstWhere must return an element of type E, so when no element passes the test it throws a StateError ("No element") instead of inventing a value.
Same family: first, last, single and reduce on an empty iterable; singleWhere also throws "Too many elements" on two matches
**orElse: () => fallback: when a real default exists; it must return the same element type
where(test).firstOrNull**: null instead of a throw; firstOrNull has been in dart:core since Dart 3.0
**firstWhereOrNull**: the same in one call, from package:collection
**fold instead of reduce**: fold takes a seed, so an empty list is not an error
A generic class or function takes a type parameter that the caller fills in: Page<T> becomes Page<User>. A bound such as T extends Comparable<T> limits which types are allowed and lets the body call that type's members.
Type safety without copies: one Page<T>, Result<T> or AsyncList<T> widget serves every model, and the compiler still checks item.name at each call site
Classes and functions: a generic class fixes T per instance (Page<User>); a generic function fixes it per call (T first<T>(List<T> xs))
Upper bounds only: there is extends but no super bound; with no bound, T behaves like Object?
Inference: maxOf(['a', 'b']) infers T = String; when inference has nothing to go on, T falls back to its bound or dynamic, so pass the type explicitly
**Versus Object or dynamic**: they compile too, but you lose the compile-time checks and need casts at every use
Expando attaches data to an object, and the key must be a real object. photo.id is an int, and numbers, strings, booleans, records and null are rejected, so the very first read _meta[photo.id] throws ArgumentError.
Why it fails: an Expando holds its keys weakly, so the key's identity must mean something; ints and strings have no identity of their own, and equal values may be the same instance or not
Not a Map: Expando<Meta> looks like Map<int, Meta> but is not: it has no keys, no length, and an entry disappears once its key object is garbage collected
Fix: key by the object itself, _meta[photo], so the data lives exactly as long as the Photo; if the data must be looked up by id and survive the object, use a Map<int, Meta> and remove entries yourself
Catch it: it fails on the first call, so one unit test covers it; in review, treat any Expando index that is not a plain object reference as a bug
It prints animal, woof, meow, then throws NoSuchMethodError. Extension members are chosen at compile time from the receiver's static type; the object's runtime type never takes part.
**a.speak()**: a is declared as Animal, so AnimalX runs even though the object is a Dog; an extension is not an override
**Dog().speak()**: both extensions apply, and the one with the more specific on type, DogX, wins
**Cat().speak()**: a real instance member always beats an extension, so CatX.speak is dead code
**d.speak()**: a dynamic receiver has no static type to match, so the call is looked up on the object at run time and fails
Takeaway: an extension is a static helper with nicer syntax; behaviour that must follow the real type belongs in a class member or a sealed hierarchy.
map, where, expand, take and skip return a lazy Iterable, not a list: your callback runs only when something actually iterates the result.
Usually that is a win: items.where(match).take(3) stops at the third match instead of scanning the whole list.
Side effects never run: items.map(save) looks like a loop and does nothing; use a for loop
Recomputed on every pass: each .length, .first or loop over a where(...) result runs the predicate again
It reads the source live: change the list before iterating and the view sees the change, or throws ConcurrentModificationError if you change it during iteration
The rule: call toList() as soon as the result is used more than once, and return a List from your own APIs unless laziness is the point
A generator produces a sequence one value at a time with yield. A sync* function returns an Iterable whose values are computed on demand; an async* function returns a Stream and may await between values.
**yield and yield*: hand out one value, or every value of another iterable or stream
sync*** is for sequences you compute step by step: walking a tree, generating combinations, a sliding window; the caller can stop early with take or first
**async*** is for when each value needs an await: a paginated API loop, reading a device or a file chunk by chunk
Nothing runs on call: the body starts only when someone iterates or listens
Cancellation: when the listener cancels, the async* body ends at its next yield, so finally blocks run; that is where to release resources
Skip them: if the data is already in memory, return a List; for a single value, return a Future
It prints created, then start, yield 1, 1, and then start, yield 1, 1 again. A sync* body does not run when the function is called; it runs only as something iterates the result, and each new iteration starts it from the top.
**count() runs nothing**: it returns an Iterable immediately, so created comes first
**.first starts a fresh iterator**: the body runs up to the first yield, hands back 1 and is abandoned there
Twice: the second .first creates another iterator, so start prints again; nothing is cached
**end never prints: nobody asked for a second element, so the loop never finishes
Fix when the work is expensive or has side effects**: iterate once with toList() and reuse the list
Uint8List, Int32List, Float64List and the rest of dart:typed_data are fixed-length lists stored as one packed block of raw numbers. Use them for binary data (file contents, image bytes, network payloads, FFI buffers), where a List<int> would waste memory and force copies.
Memory: a Uint8List stores one byte per element; a List<int> uses a 4- or 8-byte slot per element, so a 10 MB image takes 10 MB instead of 40–80 MB
Fixed length, silently truncating: there is no add, and storing 300 into a Uint8List keeps the low 8 bits, 44, without an error
Views, not copies: Uint8List.sublistView and buffer.asUint8List(offset, length) share the same bytes; sublist copies
**ByteData**: reads mixed-width fields with an explicit byte order, such as getUint32(0, Endian.little) for a binary header
Where you meet them: response.bodyBytes, File.readAsBytes, Image.memory, rootBundle.load, TransferableTypedData between isolates, Pointer.asTypedList in FFI
map is lazy: rows is not a list but a recipe that calls _toRow again on every pass. rows.first builds Row(0), then the for loop builds three more rows with ids 1, 2 and 3, so id 0 is not in byId.
Lazy means re-run: Iterable.map, where and sync* generators do their work while you iterate, and each new iteration starts the work over; the result is not cached
Side effects make it visible: _nextId++ is a side effect, so the second pass produces different objects; the same applies to expensive parsing, network calls or DateTime.now() inside map
Fix: materialise once with .toList() right where the rows are created, then read first, loop and look up from that one list
Catch it: keep functions passed to map and where pure; when you use the result more than once, or hold it in a field, call toList() immediately
The front end compiles Dart to kernel (.dill); in release gen_snapshot turns the whole program into machine code (libapp.so on Android, App.framework on iOS), after whole-program tree shaking removes everything unreachable.
From source to binary
Front end: parses and type-checks your Dart and writes kernel, a platform-independent intermediate form
Tree shaking: whole-program analysis drops every class and method nothing can reach, and type-flow analysis turns many virtual calls into direct ones
**gen_snapshot: compiles the remaining kernel to ARM64 or x64 code; there is no compiler on the device
What ships
A smaller runtime: the app still carries the Dart runtime (GC, isolates, the event loop), just without the JIT
Obfuscation**: --obfuscate --split-debug-info renames symbols and moves debug info out of the binary; keep the symbol files to read crash stacks
Web: the same code goes through dart2js or dart2wasm instead of the VM
Dart uses a generational collector. New objects go into a small young space that a scavenger cleans by copying out the survivors; objects that survive long enough are promoted to the old space, which a mostly concurrent mark-sweep collects and occasionally compacts.
Young generation
Allocation is a pointer bump, so creating an object is cheap
A scavenge copies only the live objects and frees the rest in bulk, so its cost depends on survivors, not on garbage; thousands of short-lived widgets per frame are exactly what it is built for
Scavenges pause Dart code briefly
Old generation
Marking runs concurrently with your code, sweeping runs on helper threads, and compaction happens only when the heap is fragmented
Anything long-lived (caches, big lists, decoded data kept around) is promoted and costs old-space work later
Memory model in brief
All objects live on the heap; the stack holds references and small integers stored inline as tagged values
Isolates started with Isolate.spawn form one isolate group with a shared heap and collector, while still never seeing each other's objects
What it means for your code
Short-lived allocations are fine; do not pool widgets or other small objects
Long-lived growth is the real problem: unbounded caches and objects retained by listeners or statics are what the DevTools Memory view should hunt for
Native memory (image pixels, FFI) is outside this heap, so a small Dart heap can sit next to a large process size
Dart has reflection in dart:mirrors, but only on the standalone VM. Flutter disables it because reflection lets code reach any class or member by name, which defeats tree shaking: the compiler would have to keep the whole program, framework included, in every app.
Tree shaking needs a closed world: AOT keeps only what is statically reachable; a reflectClass call or an invoke-by-name string could point anywhere, so nothing could be removed
Size and speed: mirrors also need metadata for every member at run time, which bloats the binary and blocks optimizations such as inlining
Instead: code generation: json_serializable, freezed, injectable and auto_route run through build_runner and write the code that reflection would have produced, so it is type-checked and tree-shaken like the rest; the macros experiment was cancelled in early 2025
What still works at run time: runtimeType, is checks and reified generics; but runtimeType.toString() changes under --obfuscate, so never use type names as keys or in stored data
A normal Map holds strong references, so everything in it stays alive as long as the cache does. WeakReference and Expando point at an object without keeping it alive; Finalizer only tells you afterwards that something was collected, and may never run at all.
**WeakReference<T>**: .target returns the object, or null once the GC has taken it; drop the entry then, because a Map<String, WeakReference<V>> still keeps its keys. Numbers, strings, booleans, records and null cannot be targets.
Weak is not a policy: the GC may clear an entry right after you add it, so pair a weak map with a small strong LRU for the hot set
**Expando<T>: attaches data to an object you do not own, without keeping that object alive
Finalizer<T>**: attach(object, token, detach: object) may call back after object becomes unreachable. The token must not reference the object, and the Finalizer itself must stay reachable, so keep it in a static final.
**NativeFinalizer**: from dart:ffi, for native memory: it calls a C function and is guaranteed to run, at the latest when the isolate group shuts down; not on a crash or exit
Rule: real cleanup lives in dispose, close and cancel; a Finalizer is a safety net or a leak detector, not a destructor