Dart 95 🎯 Internal Architecture: How Async/Await Desugars into State Machines
The async and await keywords are the most consequential syntactic sugar in Dart. They allow a developer to write asynchronous code that reads like synchronous code, while the compiler transforms the function into an object with a moveNext method and a state variable that records how far execution has progressed. The transformation is not a simple macro expansion; it is a structural rewrite that splits the function into resumable fragments, each corresponding to a segment of the original body between await expressions . Understanding this transformation explains why an async function returns a Future immediately, why local variables survive across await boundaries, and why the code after an await does not run until the event loop schedules it.
This chapter covers the desugaring process: the async function’s return type change, the state machine class generated for each function, the moveNext method that drives execution, and the exception routing mechanism that allows try/catch blocks to span suspension points.
Key point: An async function does not “pause” and “resume” in the sense of a thread being suspended. The function returns to its caller at the first await, and the continuation is invoked later by the event loop as a new call to moveNext. The state machine object is what makes the new call appear to continue where the previous one left off .
Why the state machine transformation exists
The single-threaded constraint problem. Dart runs on a single thread per isolate. When an async function encounters an await on a Future that has not yet completed, the function cannot block the thread while waiting. It must return control to the event loop so that other work can proceed. The state machine exists to preserve the function’s execution context—its local variables and its position—across that return, so that when the Future completes, execution can continue from the correct point .
The expression splitting problem. An await can appear inside an arbitrary expression. A statement like var x = (await a()) + b(); cannot simply be split at the await boundary without careful handling of the evaluation order. The compiler must evaluate a(), suspend, and then, upon resumption, evaluate b(), apply the + operator, and assign the result to x. The older AST-based approach used a preamble to evaluate subexpressions into temporary variables stored in a context, followed by the final expression evaluation . The newer implementation generates a state machine directly in the flow graph and copies the frame into a SuspendState object on suspension, avoiding the overhead of desugaring on the kernel AST .
The exception routing problem. A try/catch block that contains an await must be able to catch an exception that occurs asynchronously, after the function has already returned to the event loop. The state machine handles this by storing the pending error in a field and re-throwing it inside the moveNext method’s try block, so that the normal exception routing tables can dispatch it to the correct catch clause .
a. The State Machine Class and moveNext
The compiler generates a private class for each async function. The class holds the function’s parameters, its local variables, a state field (often named _state), a Completer for the returned Future, and any pending error from an asynchronous operation. The moveNext method is the entry point for execution; it is called once when the function is initially invoked, and again each time an awaited Future completes.
// Original async function
Future<String> fetchData() async {
print('Fetching...');
final data = await http.get(url);
print('Done');
return data;
}
// Conceptual state machine generated by the compiler
class _FetchDataStateMachine {
int _state = 0;
final Completer<String> _completer = Completer<String>();
dynamic _data; // local variable preserved across await
Object? _pendingError;
Future<String> get future => _completer.future;
void moveNext() {
while (true) {
try {
if (_pendingError != null) {
var error = _pendingError;
_pendingError = null;
throw error; // Re-throw inside try for catch routing
}
switch (_state) {
case 0:
print('Fetching...');
_state = 1;
final future = http.get(url);
future.then(
(value) {
_data = value;
moveNext(); // Resume on success
},
onError: (error) {
_pendingError = error;
moveNext(); // Resume on failure
},
);
return; // Suspend: return control to event loop
case 1:
print('Done');
_completer.complete(_data);
return;
}
} catch (e) {
_completer.completeError(e);
return;
}
}
}
}
The while (true) loop allows the state machine to process multiple states in a single moveNext call when no suspension occurs. If an await completes synchronously—because the Future is already completed—the then callback may be invoked synchronously, and the loop continues to the next state without returning to the event loop. The return after registering the callback is the suspension point: the function returns to its caller, and the Future returned by the async function is the Completer‘s future .
b. Expression Splitting and Variable Preservation
The compiler must preserve local variables across suspension points. In the older AST-based implementation, an AwaitTransformer would split an expression containing await into a preamble that evaluated subexpressions into temporary variables stored in a context, followed by the evaluation of the resulting expression . The newer implementation takes a different approach: when suspending, it copies the function’s frame into a SuspendState object, and when resuming, it copies the frame back onto the stack. No extra code is generated for accessing local variables, and callback closures are created lazily on the first await .
// Expression with await in the middle
var result = (await getA()) + getB();
// Conceptual split
// State 0: evaluate getA(), suspend
// State 1: resume with value, evaluate getB(), add, assign
case 0:
_tempA = getA();
_state = 1;
_tempA.then((v) { _resumeValue = v; moveNext(); });
return;
case 1:
_result = _resumeValue + getB();
_completer.complete(_result);
return;
The SuspendState object is a heap-allocated copy of the frame. It contains the local variables and the state index. When the awaited Future completes, the state machine’s moveNext is called, and the frame is restored from the SuspendState. This is more efficient than the older approach because it avoids generating temporary variables for every subexpression .
c. Exception Routing Across Suspension Points
A try/catch block that contains an await requires special handling. When the awaited Future completes with an error, the error is not thrown at the point of the await in the original code. It is delivered to the onError callback registered on the Future. The state machine must route this error to the correct catch clause.
The mechanism is a _pendingError field and a try block inside moveNext. When an asynchronous error occurs, it is stored in _pendingError, and moveNext is called. At the top of the try block, if _pendingError is set, it is re-thrown. Because the throw occurs inside the try block that wraps the switch statement, the exception is routed through the same exception handling tables that the compiler generates for synchronous try/catch blocks .
// Original with try/catch around await
Future<void> makeCoffee() async {
try {
await boilWater();
print('Water boiled');
} catch (e) {
print('Explosion: $e');
}
}
// Conceptual exception routing in moveNext
void moveNext() {
while (true) {
try {
if (_pendingError != null) {
var error = _pendingError;
_pendingError = null;
throw error; // Routes to catch below
}
switch (_state) {
case 0:
_state = 1;
boilWater().then(
(_) => moveNext(),
onError: (e) { _pendingError = e; moveNext(); },
);
return;
case 1:
print('Water boiled');
return;
}
} catch (e) {
print('Explosion: $e');
return;
}
}
}
The re-throw inside the try block is the key. Without it, the asynchronous error would propagate out of moveNext and never be caught by the catch clause that was written around the await in the source code. The compiler generates the exception handling tables for the moveNext method so that the re-thrown error is dispatched to the correct handler based on the current state .
Complete Example Session
// ============================================
// PART 1: SOURCE ASYNC FUNCTION
// ============================================
Future<int> compute() async {
print('Start');
int a = await fetchA();
int b = await fetchB(a);
return a + b;
}
// ============================================
// PART 2: STATE MACHINE CLASS
// ============================================
class _ComputeStateMachine {
int _state = 0;
final Completer<int> _completer = Completer<int>();
int _a = 0;
int _b = 0;
int _resumeValue = 0;
// ============================================
// PART 3: MOVE NEXT — STATE 0
// ============================================
void moveNext() {
while (true) {
try {
switch (_state) {
case 0:
print('Start');
_state = 1;
fetchA().then((value) {
_resumeValue = value;
moveNext();
});
return; // Suspend after first await
// ============================================
// PART 4: MOVE NEXT — STATE 1
// ============================================
case 1:
_a = _resumeValue;
_state = 2;
fetchB(_a).then((value) {
_resumeValue = value;
moveNext();
});
return; // Suspend after second await
// ============================================
// PART 5: MOVE NEXT — STATE 2
// ============================================
case 2:
_b = _resumeValue;
_completer.complete(_a + _b);
return;
}
} catch (e) {
_completer.completeError(e);
return;
}
}
}
}
// ============================================
// PART 6: THE FUTURE RETURNED TO CALLER
// ============================================
// compute() returns _ComputeStateMachine().future
// The caller receives a Future<int> immediately
// The state machine begins executing on the next microtask
// ============================================
// PART 7: EXPRESSION SPLITTING
// ============================================
// var x = (await a()) + b();
// Compiler splits into:
// State 0: _temp = a(); suspend
// State 1: x = _temp + b();
// ============================================
// PART 8: SUSPENDSTATE FRAME COPY
// ============================================
// Newer VM: frame copied to SuspendState on suspend
// Frame copied back on resume
// No per-variable code generation
// ============================================
// PART 9: EXCEPTION ROUTING
// ============================================
// onError stores _pendingError, calls moveNext
// moveNext re-throws inside try block
// Catch clauses route based on state
// ============================================
// PART 10: THE COMPLETER BRIDGE
// ============================================
// _completer.complete(value) resolves the Future
// The caller's .then() or await resumes
// The state machine object is eligible for GC
The ten parts trace the transformation from source to state machine. The critical observation is that compute() does not execute synchronously to completion. It creates a state machine, returns its Future, and returns control to the caller. The moveNext method is called later by the event loop, once for each await completion.
Quick Reference
State Machine Components
| Component | Purpose |
|---|---|
_state | Tracks current execution position |
_completer | Resolves the returned Future |
| Local variable fields | Preserve values across suspension |
_pendingError | Stores asynchronous error for re-throw |
moveNext() | Drives execution from current state |
Transformation Steps
| Step | Action |
|---|---|
| 1 | Change return type to Future<T> |
| 2 | Generate state machine class |
| 3 | Split body at await expressions |
| 4 | Register then callbacks for each await |
| 5 | Route errors via _pendingError re-throw |
| 6 | Complete _completer at end or error |
Suspension and Resumption
| Event | Mechanism |
|---|---|
| Suspend | Register callback, return from moveNext |
| Resume | Future completes, callback calls moveNext() |
| Frame preservation | Copy frame to SuspendState (newer VM) |
Best Practices
✅ Do This:
// Await at the top level of an async function
// The state machine handles the suspension cleanly
final data = await fetchData(); // ✅
// Use try/catch around await for asynchronous errors
try { await riskyOperation(); } catch (e) { } // ✅
// Understand that async functions return immediately
// The code after the first await runs later // ✅
❌ Don’t Do This:
// Assume await blocks the thread
// It suspends the function and returns to the event loop // ❌
// Use await inside a synchronous callback without an async wrapper
// The transformation requires an async function context // ❌
// Rely on exact state machine internals
// The implementation may change between VM versions // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Function returns before completion | async returns a Future immediately | await the call or use .then() |
| Error not caught | try/catch missing around await | Wrap the await in try |
| Local variable lost | Variable not captured in state machine | Ensure variable is used after await |
| Event loop starvation | Long synchronous code inside async | Break into smaller chunks |
Real-World Examples
1. Basic Async Function
Future<int> getValue() async => 42;
2. Await with Error Handling
try {
final result = await fetch();
} catch (e) {
print(e);
}
3. Multiple Awaits
final a = await getA();
final b = await getB(a);
4. State Variable
int _state = 0; // 0 = start, 1 = after first await
5. Completer Resolution
_completer.complete(result);
6. Pending Error Re-throw
if (_pendingError != null) throw _pendingError;
7. Callback Registration
future.then((v) { _resumeValue = v; moveNext(); });
8. Suspension Point
return; // after registering callback
9. Expression Split
var x = (await a()) + b(); // split at await
10. Return Type Change
Future<String> fetchData() async { ... }
Visual
The State Machine Lifecycle
┌──────────────────────────────────────────────────────────────┐
│ CALLER │
│ fetchData() │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Create state │ _state = 0 │
│ │ machine object │ _completer = Completer() │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Call moveNext() │ Executes state 0 │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Hit await │ Register callback on Future │
│ │ │ _state = 1 │
│ │ │ return; ← SUSPEND │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Return Future │ Caller receives Future immediately │
│ │ to caller │ Event loop continues │
│ └─────────────────┘ │
│ │
│ ... later, when Future completes ... │
│ │
│ ┌─────────────────┐ │
│ │ Callback calls │ _state = 1 │
│ │ moveNext() │ Resume from state 1 │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Complete │ _completer.complete(result) │
│ │ _completer │ Caller's await resumes │
│ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
Expression Splitting
┌──────────────────────────────────────────────────────────────┐
│ SOURCE: var x = (await a()) + b(); │
│ │
│ ┌─────────────────┐ │
│ │ State 0: │ │
│ │ _temp = a(); │ Evaluate a() │
│ │ _state = 1; │ │
│ │ register │ │
│ │ callback; │ │
│ │ return; │ SUSPEND │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ State 1: │ │
│ │ _value = resume;│ a() result │
│ │ _b = b(); │ Evaluate b() │
│ │ x = _value + _b;│ Apply + │
│ │ complete(x); │ Resolve Future │
│ └─────────────────┘ │
│ │
│ The await splits the expression into two states. │
│ Temporary values are preserved in state machine fields. │
│ │
└──────────────────────────────────────────────────────────────┘
Exception Routing Across Await
┌──────────────────────────────────────────────────────────────┐
│ try { await risky(); } catch (e) { handle(e); } │
│ │
│ ┌─────────────────┐ │
│ │ State 0: │ │
│ │ risky().then( │ │
│ │ (_) => moveNext(), │
│ │ onError: (e) { │
│ │ _pendingError = e; │
│ │ moveNext(); │
│ │ } │
│ │ ); │ │
│ │ return; │ SUSPEND │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Error occurs │ _pendingError = error │
│ │ moveNext called │ │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ moveNext: │ │
│ │ try { │ │
│ │ if (_pending │ │
│ │ Error != null)│ │
│ │ throw error;│ Re-throw inside try │
│ │ switch... │ │
│ │ } catch (e) { │ │
│ │ handle(e); │ Caught by original catch │
│ │ } │ │
│ └─────────────────┘ │
│ │
│ The re-throw inside the try block routes the async error │
│ to the catch clause that surrounded the await. │
│ │
└──────────────────────────────────────────────────────────────┘
SuspendState Frame Copy
┌──────────────────────────────────────────────────────────────┐
│ OLDER VM NEWER VM │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Desugar on │ │ State machine │ │
│ │ kernel AST │ │ in flow graph │ │
│ │ │ │ │ │
│ │ Temporary vars │ │ Frame copied │ │
│ │ in context │ │ to SuspendState │ │
│ │ │ │ │ │
│ │ Extra code for │ │ No extra code │ │
│ │ variable access │ │ for locals │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ The newer implementation copies the frame on suspend │
│ and restores it on resume. Local variable access is free. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Transformation | async function becomes a state machine class |
| State field | _state tracks execution position |
| Completer | Resolves the returned Future |
| moveNext | Drives execution from current state |
| Suspension | Register callback, return from moveNext |
| Resumption | Future completes, callback calls moveNext() |
| Exception routing | _pendingError re-thrown inside try block |
| Frame preservation | SuspendState copy (newer VM) |
| Expression splitting | Preamble + expression (older), flow graph (newer) |
Key takeaways:
- An
asyncfunction returns aFutureimmediately. The function body does not execute to completion before returning. The state machine is created,moveNextis called once, and execution suspends at the firstawait. The caller receives theCompleter‘s future and continues . - The state machine preserves local variables across suspension. Every variable that is used after an
awaitmust be stored in a field of the state machine class. The older VM desugared this on the kernel AST; the newer VM copies the frame into aSuspendStateobject, avoiding per-variable code generation . - Expression splitting handles
awaitinside larger expressions. A statement likevar x = (await a()) + b();is split into a preamble that evaluatesa(), a suspension, and a continuation that evaluatesb()and applies the operator. Temporary values are stored in state machine fields . - Exception routing across
awaitrequires a re-throw. When an awaitedFuturecompletes with an error, the error is stored in_pendingErrorandmoveNextis called. The error is re-thrown inside thetryblock that wraps the state switch, so the compiler’s exception handling tables route it to the correctcatchclause . - The transformation is an implementation detail, not a language contract. The state machine class is generated by the compiler and is not part of the public API. The behavior of
asyncandawaitis defined by the language specification, not by the exact structure of the generated code.
Remember: async and await are not primitives; they are a transformation. The function you write is rewritten into a class with a moveNext method, a state variable, and a Completer. The await expression becomes a registration of a callback and a return from moveNext. The code after the await becomes a new state that is entered when the callback fires. The local variables become fields on the state machine object. The try/catch blocks become exception routing tables that operate on the moveNext method. None of this is visible in the source, but it explains the behavior: the immediate return of the Future, the non-blocking suspension, the preservation of local state, and the ability to catch asynchronous errors.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!