| |

Dart 95 🎯 Internal Architecture: How Async/Await Desugars into State Machines

⏱️ Estimated read: 13 min | |

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

ComponentPurpose
_stateTracks current execution position
_completerResolves the returned Future
Local variable fieldsPreserve values across suspension
_pendingErrorStores asynchronous error for re-throw
moveNext()Drives execution from current state

Transformation Steps

StepAction
1Change return type to Future<T>
2Generate state machine class
3Split body at await expressions
4Register then callbacks for each await
5Route errors via _pendingError re-throw
6Complete _completer at end or error

Suspension and Resumption

EventMechanism
SuspendRegister callback, return from moveNext
ResumeFuture completes, callback calls moveNext()
Frame preservationCopy 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

PitfallWhy It HappensFix
Function returns before completionasync returns a Future immediatelyawait the call or use .then()
Error not caughttry/catch missing around awaitWrap the await in try
Local variable lostVariable not captured in state machineEnsure variable is used after await
Event loop starvationLong synchronous code inside asyncBreak 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

ItemValue
Transformationasync function becomes a state machine class
State field_state tracks execution position
CompleterResolves the returned Future
moveNextDrives execution from current state
SuspensionRegister callback, return from moveNext
ResumptionFuture completes, callback calls moveNext()
Exception routing_pendingError re-thrown inside try block
Frame preservationSuspendState copy (newer VM)
Expression splittingPreamble + expression (older), flow graph (newer)

Key takeaways:

  • An async function returns a Future immediately. The function body does not execute to completion before returning. The state machine is created, moveNext is called once, and execution suspends at the first await. The caller receives the Completer‘s future and continues .
  • The state machine preserves local variables across suspension. Every variable that is used after an await must 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 a SuspendState object, avoiding per-variable code generation .
  • Expression splitting handles await inside larger expressions. A statement like var x = (await a()) + b(); is split into a preamble that evaluates a(), a suspension, and a continuation that evaluates b() and applies the operator. Temporary values are stored in state machine fields .
  • Exception routing across await requires a re-throw. When an awaited Future completes with an error, the error is stored in _pendingError and moveNext is called. The error is re-thrown inside the try block that wraps the state switch, so the compiler’s exception handling tables route it to the correct catch clause .
  • 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 async and await is 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.

Was this helpful?


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!