| |

Dart 36 🎯 Asynchronous Programming Fundamentals: Future and Event Loop Execution

Dart programs run on a single thread. Every line of synchronous code executes in order, one after another, on that one thread. This is simple, predictable, and fast — until the code needs to wait. A network request takes hundreds of milliseconds. A file read takes milliseconds to seconds. A timer takes however long it was set for. If the single thread simply paused and waited for each of these operations, the entire program would freeze: no UI updates, no other work, nothing. The solution is not more threads. The solution is the event loop, and the Future is Dart’s representation of a value that will exist later.

The core idea is that waiting is not the same as blocking. A synchronous sleep blocks the thread — nothing else runs until it finishes. An asynchronous wait does not block the thread — the operation is handed off to the system, and the thread is free to continue doing other work. When the operation completes, its result is scheduled back onto the thread as an event. The event loop processes that event when the thread is ready. The Future is the handle to that eventual result: it is a placeholder object that will be filled with a value (or an error) at some point in the future.

Understanding this model requires understanding three things that are often conflated. The event loop is the mechanism that processes events one at a time. The microtask queue is a higher-priority queue that drains completely before the next event is processed. The Future is the object that represents a value that will arrive asynchronously. Each plays a distinct role, and the order in which they interact determines the order in which code runs. Getting that order wrong is the source of most async bugs in Dart.

This chapter covers three areas. First, why the event loop and Future exist — the problem of single-threaded concurrency, why blocking is unacceptable in a UI framework, and what “asynchronous” actually means on one thread. Second, how the event loop works — the event queue, the microtask queue, the scheduling rules, and the precise order in which synchronous code, microtasks, and events run. Third, how a Future represents an eventual value — the three states of a future, how completion is scheduled, and how the then, catchError, and whenComplete callbacks are queued. The chapter ends with a complete example session, a quick reference, best practices, common pitfalls, real-world examples, and diagrams showing the event loop and the queues.

Key point: Dart runs on a single thread with an event loop. Synchronous code runs to completion before anything else. Microtasks drain completely before the next event. Events are processed one at a time. A Future is a placeholder for a value that will be available later, and its callbacks run as microtasks after the future completes .


Why the event loop and Future exist

The blocking problem. A single-threaded program that performs a blocking operation stops entirely. If the operation is a network request, the program cannot respond to user input, cannot update the display, and cannot perform any other work until the request finishes. In a server, this means one slow request blocks every other request. In a UI, it means the interface freezes. The event loop solves this by never blocking. Instead of waiting for an operation to complete, the program schedules a callback and continues. When the operation completes, the callback is queued as an event and processed later. The thread is never idle waiting; it is always processing whatever is ready .

The one-thread-at-a-time problem. Dart’s concurrency is not parallelism. There is one thread, and it does one thing at a time. This is a deliberate design choice: a single thread means no locks, no race conditions on shared memory, and no deadlocks. But it also means that long-running synchronous code starves everything else. If a function loops for two seconds without yielding, no events are processed during those two seconds. The event loop model requires that work be broken into small pieces that complete quickly, so the loop can move on to the next event. This is why asynchronous APIs exist: they let a long-running operation (a network request, a file read) be expressed as a series of small completions rather than one blocking call .

The ordering problem. When multiple asynchronous operations are in flight, the order in which their callbacks run is not arbitrary. Dart defines two queues: the event queue and the microtask queue. Microtasks have higher priority: they drain completely before the next event is processed. This distinction matters because Future callbacks are scheduled as microtasks, while I/O and timer callbacks are scheduled as events. A Future that completes synchronously still schedules its callbacks as microtasks, which means they run after the current synchronous code finishes but before any pending events. Understanding this ordering is the key to predicting what a program will do .

The value-later problem. A function that performs an asynchronous operation cannot return the result directly, because the result does not exist yet. It must return something that represents the result. That something is a Future. The caller can attach callbacks to the future, and those callbacks run when the result arrives. The future is a first-class value: it can be stored, passed, and composed. This is what makes asynchronous code composable. A function that returns a Future<int> can be chained with then, awaited with await, or combined with other futures using Future.wait. The future is the interface between the operation and the code that needs its result .

The error-propagation problem. Synchronous errors propagate up the call stack until caught. Asynchronous errors cannot do this, because the call stack is gone by the time the error occurs. A Future carries both a value and an error. When an operation fails, the future completes with an error instead of a value. Callbacks attached with then receive the error if it is not handled, and catchError can intercept it. This unifies the success and failure paths into a single abstraction. A future is not just “a value later” — it is “a value or an error later” .

The trade-off. The event loop model makes concurrency safe on a single thread, but it requires that code never block. A single sleep call, a single long loop, or a single synchronous I/O operation breaks the model and freezes the program. The model also introduces ordering complexity: the interaction between microtasks and events is subtle, and code that assumes a particular order can fail in ways that are difficult to debug. The Future abstraction hides much of this complexity, but not all of it. The trade-off is between the safety of single-threaded concurrency and the discipline required to never block the thread and to understand the queue ordering.


a. The event loop and the two queues

The Dart runtime has two queues: the event queue and the microtask queue. The event loop processes them according to a fixed priority rule: the microtask queue drains completely before the next event is processed.

The event queue holds external events: I/O completions, timer callbacks, user input, and messages from other isolates. These are the coarse-grained units of work. Each event is processed to completion before the next event begins.

The microtask queue holds internal callbacks: Future completions, scheduleMicrotask calls, and the continuations of await expressions. These are the fine-grained units of work. They run after the current synchronous code finishes and before the next event.

The ordering rule is:

  1. Run the current synchronous code to completion.
  2. Drain the microtask queue completely.
  3. Process the next event from the event queue.
  4. After that event’s synchronous code, drain the microtask queue again.
  5. Repeat.

This means a microtask scheduled during the processing of an event runs before the next event, even if the next event was queued first. Microtasks have absolute priority over events .

import 'dart:async';

void main() {
  print('1'); // synchronous

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

  Future(() => print('3')); // event

  print('4'); // synchronous
}

The output is:

1
4
2
3

The synchronous code (1, 4) runs first. The microtask (2) runs next, draining the microtask queue. The event (3) runs last, because it is an event and microtasks take priority .

The Future constructor schedules its callback as an event, not a microtask. But the callbacks attached to a future with then are scheduled as microtasks. This distinction is the source of much of the ordering complexity.

b. The three states of a Future

A Future has three states: uncompleted, completed with a value, and completed with an error. The future starts uncompleted. At some point it completes with either a value or an error. Once completed, the state never changes.

A future is created with Future, Future.value, Future.error, or by calling an async function:

final future = Future.value(42); // already completed with 42

The state of the future can be observed by attaching callbacks:

  • then(onValue) — runs when the future completes with a value.
  • catchError(onError) — runs when the future completes with an error.
  • whenComplete(callback) — runs when the future completes, regardless of value or error.

Callbacks attached to an already-completed future still run asynchronously. A then on a Future.value does not run synchronously; it schedules a microtask:

void main() {
  print('before');

  Future.value(1).then((v) => print('value: $v'));

  print('after');
}

Output:

before
after
value: 1

The then callback runs after the synchronous code, even though the future was already complete when then was called. This is because the callback is scheduled as a microtask, and microtasks run after the current synchronous code .

This behavior is deliberate. It ensures that callbacks always run asynchronously, so code that attaches a callback can rely on consistent ordering regardless of whether the future is already complete. The alternative — running synchronously when already complete — would make the timing of callbacks unpredictable.

c. Scheduling and the callback queues

When a future completes, its callbacks are scheduled as microtasks. The order in which multiple callbacks are attached determines the order in which they run, and the order in which futures complete determines the order in which their callbacks are queued.

void main() {
  final f = Future.value(1);

  f.then((v) => print('first: $v'));
  f.then((v) => print('second: $v'));

  print('synchronous');
}

Output:

synchronous
first: 1
second: 1

The then callbacks are microtasks. They run after the synchronous code, in the order they were attached. Both receive the same value because both are attached to the same future .

When multiple futures complete, their callbacks are queued in completion order:

void main() {
  Future.delayed(Duration.zero, () => print('A'));
  Future.delayed(Duration.zero, () => print('B'));
  scheduleMicrotask(() => print('microtask'));
  print('sync');
}

Output:

sync
microtask
A
B

The synchronous code runs first. The microtask (microtask) runs next, draining the microtask queue. Then the events (A, B) run in the order they were scheduled. The Future.delayed callbacks are events, not microtasks, so they wait for the microtask queue to empty .

The Future constructor and Future.delayed schedule their callbacks as events. The then method schedules its callback as a microtask. This means a chain of then calls on a single future runs as microtasks, while the completion of the future itself (the event that triggers the chain) runs as an event.


Complete Example Session

// ============================================
// PART 1: SYNCHRONOUS VS ASYNCHRONOUS
// ============================================

// Synchronous code runs to completion before
// anything else. Asynchronous callbacks run later.

void main() {
  print('A'); // synchronous

  Future(() => print('B')); // event

  print('C'); // synchronous
}

// Output: A, C, B
// The Future callback is scheduled as an event,
// so it runs after the synchronous code.


// ============================================
// PART 2: MICROTASKS VS EVENTS
// ============================================

// Microtasks drain completely before the next
// event is processed.

void main() {
  print('1');

  scheduleMicrotask(() => print('2')); // microtask
  Future(() => print('3'));            // event
  scheduleMicrotask(() => print('4')); // microtask

  print('5');
}

// Output: 1, 5, 2, 4, 3
// Synchronous: 1, 5
// Microtasks: 2, 4 (drained completely)
// Events: 3


// ============================================
// PART 3: THE THREE STATES OF A FUTURE
// ============================================

// A future is uncompleted, then completes with
// a value or an error.

void main() {
  final future = Future.value(42);

  future.then((v) => print('value: $v'));
  future.catchError((e) => print('error: $e'));
  future.whenComplete(() => print('complete'));

  print('synchronous');
}

// Output:
// synchronous
// value: 42
// complete
// The then and whenComplete callbacks are
// microtasks. They run after the synchronous code.


// ============================================
// PART 4: THEN ON AN ALREADY-COMPLETED FUTURE
// ============================================

// Even a completed future schedules its callbacks
// asynchronously.

void main() {
  print('before');

  Future.value(1).then((v) => print('value: $v'));

  print('after');
}

// Output: before, after, value: 1
// The then callback does not run synchronously,
// even though the future is already complete.


// ============================================
// PART 5: FUTURE CONSTRUCTOR VS FUTURE.VALUE
// ============================================

// Future() schedules an event. Future.value()
// creates an already-completed future whose
// then callbacks are microtasks.

void main() {
  Future(() => print('event'));         // event
  Future.value(1).then((v) => print('microtask')); // microtask

  print('sync');
}

// Output: sync, microtask, event
// The microtask (then callback) runs before the
// event (Future constructor callback).


// ============================================
// PART 6: ERROR PROPAGATION
// ============================================

// A future can complete with an error. The error
// propagates to catchError callbacks.

void main() {
  Future.error(Exception('boom'))
    .then((v) => print('value: $v'))
    .catchError((e) => print('caught: $e'))
    .whenComplete(() => print('done'));
}

// Output:
// caught: Exception: boom
// done
// The then callback is skipped because the future
// completed with an error. catchError handles it.


// ============================================
// PART 7: CHAINING THEN CALLBACKS
// ============================================

// Each then returns a new future. The callbacks
// run in order as microtasks.

void main() {
  Future.value(1)
    .then((v) => v + 1)
    .then((v) => v * 2)
    .then((v) => print('result: $v'));

  print('synchronous');
}

// Output:
// synchronous
// result: 4
// (1 + 1 = 2, 2 * 2 = 4)


// ============================================
// PART 8: MULTIPLE FUTURES AND ORDERING
// ============================================

// When multiple futures complete, their callbacks
// are queued in completion order.

void main() {
  Future.delayed(Duration.zero, () => print('A'));
  Future.delayed(Duration.zero, () => print('B'));
  Future.delayed(Duration.zero, () => print('C'));

  print('sync');
}

// Output: sync, A, B, C
// The delayed callbacks are events, queued in
// the order they were scheduled.


// ============================================
// PART 9: MICROTASK STARVATION
// ============================================

// A microtask that schedules another microtask
// can starve the event queue.

void main() {
  scheduleMicrotask(() {
    print('microtask 1');
    scheduleMicrotask(() => print('microtask 2'));
  });

  Future(() => print('event'));

  print('sync');
}

// Output: sync, microtask 1, microtask 2, event
// The event waits until the microtask queue is
// completely empty. A long chain of microtasks
// delays all events.


// ============================================
// PART 10: THE EVENT LOOP IN A NUTSHELL
// ============================================

void main() {
  print('1: sync');

  Future(() => print('5: event'));

  Future.value(2).then((v) => print('3: microtask'));

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

  print('2: sync');
}

// Output:
// 1: sync
// 2: sync
// 3: microtask
// 4: microtask
// 5: event
//
// Order:
//   1. Synchronous code runs to completion.
//   2. Microtask queue drains completely.
//   3. Next event is processed.

The ten parts show the synchronous-first rule, microtask priority over events, the three states of a future, asynchronous callback scheduling even for completed futures, the difference between Future() and Future.value(), error propagation, chaining, event ordering, microtask starvation, and the full ordering in one example.


Quick Reference

The Two Queues

QueueContentsPriority
MicrotaskFuture.then, scheduleMicrotask, await continuationsHigher — drains completely
EventI/O, timers, Future() constructor, user inputLower — one at a time

The Ordering Rule

StepWhat runs
1Current synchronous code
2All microtasks
3Next event
4All microtasks scheduled during that event
5Repeat

Future States

StateMeaning
UncompletedNo value or error yet
Completed with valuethen callbacks receive the value
Completed with errorcatchError callbacks receive the error

Scheduling

CallScheduled as
Future(() => ...)Event
Future.delayed(...)Event
Future.value(1).then(...)Microtask
scheduleMicrotask(...)Microtask
await continuationMicrotask

Common Output Patterns

CodeOutput
print('A'); Future(() => print('B')); print('C');A, C, B
print('A'); scheduleMicrotask(() => print('B')); print('C');A, C, B
Future.value(1).then((_) => print('X')); print('Y');Y, X
scheduleMicrotask(() => print('M')); Future(() => print('E'));M, E

Best Practices

✅ Do This:

// Yield to the event loop in long-running loops
await Future.delayed(Duration.zero);                                   // ✅
// Use async/await for readability
final data = await fetchData();                                        // ✅
// Handle errors with try/catch or catchError
try { await risky(); } catch (e) { handle(e); }                        // ✅
// Understand that then callbacks are microtasks
Future.value(1).then((v) => print(v));                                 // ✅
// Use Future.wait for parallel operations
final results = await Future.wait([fetchA(), fetchB()]);               // ✅

❌ Don’t Do This:

// Don't block the event loop with synchronous work
sleep(Duration(seconds: 1));                                           // ❌
// Don't assume then runs synchronously
Future.value(1).then((v) => print(v)); print('after');                 // ❌ (after prints first)
// Don't starve the event queue with microtask chains
scheduleMicrotask(() => scheduleMicrotask(() => ...));                 // ❌
// Don't ignore errors
Future.error('boom'); // unhandled                                      // ❌
// Don't use Future for synchronous values
Future.value(computeSync());                                           // ❌ (use sync)

Common Pitfalls

PitfallWhy It HappensFix
Callback order wrongAssuming then runs synchronouslythen callbacks are microtasks
Event delayed by microtasksLong microtask chainBreak work into events
Unhandled errorNo catchError or try/catchAttach error handler
await in a loop is slowEach iteration waitsUse Future.wait for parallel
Synchronous block freezes programLong loop or sleepYield with await Future.delayed
Future() callback runs before thenMisunderstanding event vs microtaskthen is microtask; Future() is event
Timer callback before microtaskAssuming timers have priorityMicrotasks always drain first

Real-World Examples

1. Synchronous First

print('A');
Future(() => print('B'));
print('C'); // A, C, B

2. Microtask Priority

scheduleMicrotask(() => print('M'));
Future(() => print('E')); // M, E

3. Completed Future Is Still Async

Future.value(1).then((v) => print(v)); // runs later

4. Error Handling

Future.error('boom').catchError((e) => print(e));

5. Chaining

Future.value(1).then((v) => v + 1).then((v) => print(v));

6. Parallel Futures

await Future.wait([fetchA(), fetchB()]);

7. Yield to Event Loop

await Future.delayed(Duration.zero);

8. Timer as Event

Future.delayed(Duration(seconds: 1), () => print('timer'));

9. When Complete

future.whenComplete(() => print('done'));

10. Microtask Starvation

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

Visual

The Event Loop

┌──────────────────────────────────────────────────────────────┐
│  THE EVENT LOOP                                              │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  SYNCHRONOUS CODE                                    │    │
│  │  runs to completion                                  │    │
│  └──────────────────────────────────────────────────────┘    │
│         │                                                    │
│         ▼                                                    │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  MICROTASK QUEUE                                     │    │
│  │  Future.then, scheduleMicrotask, await continuations │    │
│  │                                                      │    │
│  │  DRAINS COMPLETELY before next event                 │    │
│  └──────────────────────────────────────────────────────┘    │
│         │                                                    │
│         ▼                                                    │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  EVENT QUEUE                                         │    │
│  │  I/O, timers, Future() constructor, user input       │    │
│  │                                                      │    │
│  │  ONE EVENT at a time                                 │    │
│  └──────────────────────────────────────────────────────┘    │
│         │                                                    │
│         ▼                                                    │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  MICROTASK QUEUE (again)                             │    │
│  │  drains completely                                   │    │
│  └──────────────────────────────────────────────────────┘    │
│         │                                                    │
│         └──────────────► repeat                              │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Microtasks vs Events Ordering

┌──────────────────────────────────────────────────────────────┐
│  ORDERING EXAMPLE                                            │
│                                                              │
│  print('1');                        → 1 (sync)               │
│  scheduleMicrotask(() => print('2')); → queued as microtask  │
│  Future(() => print('3'));          → queued as event        │
│  scheduleMicrotask(() => print('4')); → queued as microtask  │
│  print('5');                        → 5 (sync)               │
│                                                              │
│  Execution order:                                            │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  1. Sync:        1, 5                                │    │
│  │  2. Microtasks:  2, 4                                │    │
│  │  3. Events:      3                                   │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  Final output: 1, 5, 2, 4, 3                                 │
│                                                              │
│  The microtask queue drains completely before the event      │
│  queue is touched, even though the event was queued first.   │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Future States

┌──────────────────────────────────────────────────────────────┐
│  FUTURE STATES                                               │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  UNCOMPLETED                                         │    │
│  │  no value, no error                                  │    │
│  └──────────────────────────────────────────────────────┘    │
│         │                                                    │
│         ├──────────────────────┬───────────────────────┐     │
│         ▼                      ▼                       │     │
│  ┌──────────────┐      ┌──────────────┐                │     │
│  │  COMPLETED   │      │  COMPLETED   │                │     │
│  │  WITH VALUE  │      │  WITH ERROR  │                │     │
│  │              │      │              │                │     │
│  │  then()      │      │  catchError()│                │     │
│  │  runs        │      │  runs        │                │     │
│  └──────────────┘      └──────────────┘                │     │
│         │                      │                       │     │
│         └──────────────────────┴───────────────────────┘     │
│                                │                             │
│                                ▼                             │
│                    ┌──────────────────────┐                  │
│                    │  whenComplete()      │                  │
│                    │  runs in both cases  │                  │
│                    └──────────────────────┘                  │
│                                                              │
│  Once completed, the state never changes.                    │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Microtask Starvation

┌──────────────────────────────────────────────────────────────┐
│  MICROTASK STARVATION                                        │
│                                                              │
│  scheduleMicrotask(() {                                      │
│    print('microtask 1');                                     │
│    scheduleMicrotask(() => print('microtask 2'));            │
│  });                                                         │
│                                                              │
│  Future(() => print('event'));                               │
│                                                              │
│  print('sync');                                              │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  Execution order:                                    │    │
│  │                                                       │    │
│  │  1. sync                                             │    │
│  │  2. microtask 1                                      │    │
│  │  3. microtask 2  ← scheduled during microtask 1      │    │
│  │  4. event        ← waits for microtask queue to      │    │
│  │                     completely empty                 │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  A microtask that schedules another microtask delays         │
│  every event. An infinite chain of microtasks would          │
│  starve the event queue entirely, freezing I/O and           │
│  timers.                                                     │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Event loopProcesses events one at a time on a single thread
Microtask queueHigher priority; drains completely before events
Event queueLower priority; one event at a time
FuturePlaceholder for a value or error that will arrive later
Future statesUncompleted, completed with value, completed with error
then callbackScheduled as a microtask
Future() callbackScheduled as an event
await continuationScheduled as a microtask
Ordering ruleSync → microtasks → event → microtasks → repeat
BlockingNever block the thread; yield to the event loop

Key takeaways:

  • Dart is single-threaded with an event loop. Synchronous code runs to completion before anything else. There is no parallelism on the main isolate, only concurrency through the event loop .
  • Microtasks have absolute priority over events. The microtask queue drains completely before the next event is processed. This is why then callbacks run before Future() callbacks, even if the Future() was scheduled first .
  • A Future is a placeholder for a value or error. It starts uncompleted and eventually completes with either a value or an error. Once completed, the state never changes.
  • then callbacks are microtasks. Even a Future.value schedules its then callbacks asynchronously. The callback never runs synchronously, even if the future is already complete.
  • Future() and Future.delayed schedule events. Their callbacks are events, not microtasks, so they run after the microtask queue is empty.
  • await continuations are microtasks. When an await expression completes, the code after it runs as a microtask. This means the code after an await runs before any pending events.
  • Never block the thread. A synchronous sleep, a long loop, or a blocking I/O call freezes the entire program. Use asynchronous APIs and yield to the event loop.
  • Microtask chains can starve events. A microtask that schedules another microtask delays every event. Infinite chains freeze timers and I/O. Break long work into events.

Remember: The Future and the event loop are two sides of the same coin. The event loop is the mechanism that processes asynchronous work on a single thread. The Future is the object that represents the result of that work. The ordering rules — synchronous first, then microtasks, then events — determine what a program does and when. Understanding these rules is not optional for Dart developers, because the most common async bugs come from assuming the wrong order. A then callback does not run synchronously. A microtask runs before an event. An await continuation runs as a microtask. Once these rules are internalized, the behavior of asynchronous Dart code becomes predictable, and the event loop stops being a mystery and becomes a mental model that explains every output.



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!