Part 1: Event Loop & Async in Dart
← All Articles

Event Loop & Async in Dart

Why single-threaded ≠ race-condition-free. Here's why race conditions happen in Dart — and what you need to understand before reaching for a Mutex.

In this article:

  • How Isolates and the Event Loop work
  • Microtask Queue vs Event Queue — priority and ordering
  • Why async/await creates an illusion of parallelism
  • Race conditions in a single-threaded language — real examples
  • Completer: the building block of async synchronization
  • Why Dart needs a Mutex

#How Dart's Thread Works

Isolate — the Unit of Execution

Each Dart Isolate is one thread with its own heap. Isolates don't share memory. Communication is only possible via messages (SendPort / ReceivePort).

  Isolate (main)             Isolate (worker)
 ┌────────────────┐         ┌────────────────┐
 │  Event Loop    │         │  Event Loop    │
 │  Heap          │         │  Heap          │
 └───────┬────────┘         └───────┬────────┘
         └─── SendPort/ReceivePort ─┘

A Flutter app runs in one main isolate. All your code — widgets, state management, business logic — executes on a single thread.

Event Loop: Two Queues, Strict Priority

The Event Loop is an infinite cycle that pulls tasks from two queues:

Microtask Queue (HIGH priority):

  • scheduleMicrotask()
  • Future.then() / Future callbacks
  • Completer.sync().complete() — fires synchronously
  • async/await — continuation after await

Event Queue (LOW priority):

  • Timer, Future.delayed
  • Future() constructor
  • I/O callbacks, HTTP responses
  • Platform events (gestures, input, rendering)

Algorithm of one iteration:

  1. Execute all tasks from the Microtask Queue
  2. Execute one task from the Event Queue
  3. Repeat
Microtasks have absolute priority. If microtasks endlessly generate new microtasks — the Event Queue will never be processed (UI freezes).

What Goes Where?

Microtask Queue:

scheduleMicrotask(() => print('microtask'));

Future.value(42).then((v) => print(v));

final c = Completer<void>.sync();
c.future.then((_) => print('sync completer'));
c.complete(); // fires HERE, synchronously

Event Queue:

Timer(Duration.zero, () => print('timer'));
Future.delayed(Duration.zero, () => print('delayed'));
Future(() => print('future constructor'));

Execution Order Demo

void main() {
  print('1. synchronous');
  Future(() => print('5. event queue'));
  scheduleMicrotask(() => print('3. microtask'));
  Future.value().then((_) => print('4. future.then'));
  print('2. synchronous');
}

// Output:
// 1. synchronous
// 2. synchronous
// 3. microtask
// 4. future.then
// 5. event queue

Synchronous first → Microtasks → Events.


#async/await — the Illusion of Parallelism

async/await is syntactic sugar over Future. Each await is a point where the function suspends and the Event Loop can process other tasks.

Future<void> fetchData() async {
  print('A: start');                // synchronous
  final data = await http.get(url); // suspend here
  print('B: got data');              // resumes after response
}

fetchData();
print('C: after call');

Execution order: A → C → (HTTP completes) → B

await does NOT block the thread. It frees the thread for other work. When the Future completes, the continuation goes into the Microtask Queue.

async/await as Futures

// async/await:
Future<int> calculate() async {
  final a = await getA();
  final b = await getB(a);
  return a + b;
}

// Equivalent:
Future<int> calculate() {
  return getA().then((a) =>
    getB(a).then((b) => a + b));
}

Each await = .then() = the Event Loop takes control.


#Race Conditions in a Single-Threaded Language

If everything is single-threaded, where do race conditions come from?

Classic Example

int counter = 0;

Future<void> increment() async {
  final value = counter;                // read
  await Future.delayed(Duration.zero);  // yield
  counter = value + 1;                  // write
}

void main() async {
  await Future.wait([
    increment(),
    increment(),
    increment(),
  ]);
  print(counter); // Expected 3, got 1!
}

What happens:

Step 1 — all three read counter (0):
  #1: value = 0
  #2: value = 0
  #3: value = 0
  → all suspended at await

Step 2 — all three write (0 + 1):
  #1: counter = 1
  #2: counter = 1  ← overwrite!
  #3: counter = 1  ← overwrite!

Between read and write there's an await. During the await, other Futures read a stale value.

Real-World Example: Cart

class CartController {
  List<Product> items = [];

  Future<void> addItem(Product product) async {
    final currentItems = [...items];    // read
    await api.addToCart(product);        // await!
    items = [...currentItems, product]; // write
  }
}

User taps "Add" twice quickly:

addItem(A): reads [] → API call → writes [A]
addItem(B): reads [] → API call → writes [B] ← lost A!

Product A is lost — addItem(B) read an empty list before addItem(A) wrote its result.

When Race Conditions DON'T Occur

// ✅ No await between read and write
void incrementSync() {
  counter++;
}

// ✅ No dependency on current state
Future<void> setValue() async {
  counter = await fetchFromApi();
}

// ✅ Read AFTER await
Future<void> incrementCorrectly() async {
  await someOperation();
  counter++; // read + write in one synchronous block
}
Rule: if there's an await between reading state and writing state — you're potentially vulnerable.

#Completer: the Foundation of Async Synchronization

Completer<T> separates the creation of a Future from its completion.

final completer = Completer<int>();

completer.future.then((value) => print('Got: $value'));
completer.complete(42); // → "Got: 42"

Completer vs Completer.sync

This difference is critical for mutex performance.

Regular Completer — callback via microtask:

final c = Completer<void>();
c.future.then((_) => print('callback'));
c.complete();
print('after');
// Output: after → callback

Completer.sync — callback fires immediately:

final cs = Completer<void>.sync();
cs.future.then((_) => print('callback'));
cs.complete();
print('after');
// Output: callback → after

Completer.sync() invokes callbacks immediately upon complete() — no Microtask Queue overhead. Saves one Event Loop tick per operation, critical for mutex chains.

If a sync callback calls complete() on another Completer.sync(), you get synchronous recursion. For linear chains (as in a mutex), this is safe.

#Why Dart Needs a Mutex

Mutex (mutual exclusion) guarantees only one async operation runs at a time.

Without Mutex:
  #1: read(0) ── await ── write(1)
  #2: read(0) ── await ── write(1)  ← BAD
  #3: read(0) ── await ── write(1)  ← BAD

With Mutex:
  #1: read(0) ── await ── write(1) |
  #2:                               read(1) ── await ── write(2) |
  #3:                                                              read(2) ...

A Mutex does NOT block the thread. It queues tasks — waiting tasks await through Dart primitives while the Event Loop continues.

Basic Mutex API

abstract class Mutex {
  bool get locked;
  Future<void Function()> lock();
  Future<T> synchronize<T>(Future<T> Function() action);
}
  • lock() — acquires the lock, returns an unlock function
  • synchronize() — lock → execute → unlock (with try/finally)
  • locked — check current state

Key Event Loop Invariants for Mutex

Four properties that make correct mutexes possible:

  1. Synchronous code is not interrupted. Between two awaits, no other Dart code runs.
  2. await is a switch point. The Event Loop may execute another task.
  3. Microtask order is predictable. FIFO. Completer.sync().complete() fires synchronously.
  4. Future.then() runs in a microtask. Placed in the Microtask Queue on Future completion.

#What's Next

In Part 2 we'll examine three concrete Mutex implementations:

  • SimpleMutex — Future chain
  • QueueMutex — explicit queue via DoubleLinkedQueue<Completer>
  • LinkedMutex — implicit linked list via Completers

We'll compare them by memory, performance, API safety, and GC pressure — and show why LinkedMutex is the optimal choice.