Brain
EngineeringFlutter

Async and the Event Loop

Futures, async and await, the microtask and event queues, and error handling in async code.

Synchronous vs asynchronous code

In programming, especially in Flutter and Dart, understanding the difference between asynchronous and synchronous data handling is essential for building responsive applications.

Synchronous code

Synchronous data processing happens immediately and in sequence. When you request data synchronously, your code waits (blocks) until the operation completes before moving to the next line.

Characteristics

  1. Blocking: The program waits for the operation to complete before continuing execution
  2. Sequential: Operations happen one after another in a defined order
  3. Predictable flow: Easier to follow and debug as code executes in the order it's written
  4. Immediate results: Data is available right after the operation

Asynchronous code

Asynchronous data processing allows your program to continue execution while waiting for an operation to complete. When the operation finishes, a callback is triggered or a Future/Promise resolves with the data.

Characteristics

  1. Non-blocking: The program continues execution while waiting for the operation
  2. Parallel: Multiple operations can happen simultaneously
  3. Event-driven: Relies on callbacks, Futures, or Streams to handle completion
  4. Delayed results: Data becomes available at some point in the future

Side by side

AspectSynchronousAsynchronous
ExecutionBlocks until completeContinues execution while waiting
Use casesSimple, fast operationsNetwork requests, file I/O, long calculations
Code complexitySimpler, linearMore complex with callbacks/futures
Performance impactCan freeze UI if operation takes longKeeps UI responsive
Error handlingTry-catch blocksTry-catch with async/await or .catchError()
Data flowDirect return valuesFutures, Streams, or callbacks

Picking one

Use Synchronous Processing When:

  • The operation is simple and quick
  • The data is needed immediately before continuing
  • The code flow is more important than responsiveness
  • Working with in-memory data

Use Asynchronous Processing When:

  • Dealing with I/O operations (network, file, database)
  • The operation might take a long time
  • UI responsiveness is critical
  • You need to perform multiple operations concurrently

In Flutter applications, asynchronous data handling is especially important to keep the UI responsive while fetching data from APIs, reading files, or performing database operations.

Streams are one of the core concepts in Flutter for handling asynchronous data. They represent a sequence of asynchronous events, similar to a pipe where you put values in one end and listen for them at the other end.


Future vs Stream

Both hand you a value later. The difference is how many values, and whether the thing ever finishes.

Future

A Future represents a single asynchronous value that will be available later.

Think of it like a promise of one result.

Example:

Future<String> fetchUser() async {
  return "John";
}

Usage:

fetchUser().then((user) {
  print(user);
});

or

String user = await fetchUser();

Stream

A Stream represents multiple asynchronous values over time.

Think of it like a data pipeline or event flow.

Example:

Stream<int> counter() async* {
  for (int i = 1; i <= 5; i++) {
    await Future.delayed(Duration(seconds: 1));
    yield i;
  }
}

Usage:

counter().listen((value) {
  print(value);
});

or

await for (var value in counter()) {
  print(value);
}

Side by side

FeatureFutureStream
ValuesSingle valueMultiple values
CompletionOnceMultiple events
UsageHTTP requestWebSocket, UI events
Methodsthen(), catchError()listen(), map(), where()

Example analogy:

Future → ordering food (one result)

Stream → YouTube live stream (continuous data)

async, async*, and sync*

When working with asynchronous programming in Dart and Flutter, you'll encounter different function modifiers: async, async*, and sync*. Each serves a specific purpose in handling different types of operations.

async

The async modifier is used for functions that perform asynchronous operations and return a single value in the future.

Key characteristics

  • Returns a Future<T> object
  • Allows the use of await keyword inside the function
  • Completes with a single value (or error)
  • Used for one-time asynchronous operations

async*

The async* modifier is used to create asynchronous generator functions that produce a sequence of values over time.

Key characteristics

  • Returns a Stream<T> object
  • Uses yield to emit values to the stream
  • Can emit multiple values over time
  • Used for continuous asynchronous data sources

sync*

The sync* modifier creates synchronous generator functions that produce a sequence of values on demand.

Key characteristics

  • Returns an Iterable<T> object
  • Uses yield to emit values to the iterable
  • Values are generated on-demand when iterated
  • No asynchronous operations inside (no await)
  • Computation pauses between yielded values but doesn't involve the event loop

Side by side

Featureasyncasync*sync*
Return typeFuture<T>Stream<T>Iterable<T>
Number of valuesSingleMultipleMultiple
TimingAsynchronousAsynchronousSynchronous
Use withawaitawait forfor or forEach
Emit values withreturnyieldyield

Picking one

  • Use async: For operations that return a single result after some asynchronous work (API calls, file operations)
  • Use async*: For operations that produce multiple values asynchronously over time (WebSocket connections, continuous sensor data, real-time updates)
  • Use sync*: For operations that generate a sequence of values synchronously but need to be computed on-demand (number sequences, pagination, custom iterables)

In Flutter development, these are particularly useful for:

  • async: HTTP requests, database operations
  • async*: Real-time UI updates, animations with varying states, event streams
  • sync*: Efficient processing of large datasets, custom collection behaviors

What async and await compile to

In Dart, async/await is syntactic sugar over Futures and state machines.

Example:

Future<int> getData() async {
  int a = await fetchA();
  int b = await fetchB();
  return a + b;
}

Internally this becomes something like:

Future<int> getData() {
  return fetchA().then((a) {
    return fetchB().then((b) {
      return a + b;
    });
  });
}

But the real compilation is closer to a state machine:

Pseudo representation:

state0 -> call fetchA
state1 -> wait result
state2 -> call fetchB
state3 -> return result

So the compiler transforms:

async function
↓
state machine
↓
Future chain

Benefits:

  • cleaner code

  • easier error handling

  • sequential logic


The event loop and microtasks

Dart runs async code on a single thread inside an isolate. An event loop decides what runs next by pulling work from two queues:

Microtask Queue (higher priority)
Event Queue (lower priority)

The cycle:

Synchronous code
       |
Drain ALL microtasks
       |
Take ONE event
       |
Run that event to completion
       |
Drain ALL microtasks it created
       |
Take the next event
       |
Repeat

Synchronous code runs first

Whatever is already on the call stack finishes before the loop looks at either queue.

print("A");
print("B");

Both print immediately, in that order.


The microtask queue

The microtask queue holds small pieces of work that must run before the next event. You schedule one with:

scheduleMicrotask(() {
  print("microtask");
});

or

Future.microtask(() {
  print("microtask");
});

Callbacks attached to an already completed future land here too:

future.then(...)
future.catchError(...)
future.whenComplete(...)

Dart drains the whole microtask queue before it touches the event queue.


The event queue

The event queue holds everything else: timers, Future.delayed, Future(...), I/O, network and platform responses, user input, and platform messages.

Timer(Duration(seconds: 1), () {
  print("Timer");
});

Future(() => print("event task"));

Dart takes one event at a time and runs it to the end.


The exact draining rule

The microtask queue does not drain one item at a time. It drains completely, including microtasks added while it is draining.

run all microtasks
run ONE event
run all microtasks
run ONE event
...

An event is never cut short. If an event schedules microtasks while it runs, Dart still finishes the event, then drains those microtasks, then takes the next event.

Event 1
   |
   creates Microtask A
   creates Microtask B
   |
Event 1 finishes
   |
Microtask A
Microtask B
   |
Event 2

This is why a microtask that schedules another microtask can starve the event queue forever. Keep microtask work tiny.

A flowchart of one turn of the Dart event loop. Sync code finishes and main returns, then the loop asks whether the microtask queue is empty. If it is not, it runs the next microtask and asks again. Only once the queue is empty does it take exactly one event off the event queue, run it, and go back to draining microtasks.

Classification cheat sheet

The whole topic reduces to putting each line in one of three buckets.

CodeBucket
Plain statementssync
Future.sync(f)sync
Body of an async function up to the first awaitsync
scheduleMicrotask(f)microtask
Future.microtask(f)microtask
.then on an already completed futuremicrotask
Everything after an awaitmicrotask
Future(f)event
Future.delayed(...), including Duration.zeroevent
Timer, Timer.periodic, I/O, isolate messages, gesturesevent

The one that catches people: Future(() {}) is the event queue, not a microtask. It looks like the most basic future, so it gets assumed to run first. It runs last.

Which constructs feed which queue. scheduleMicrotask, Future.microtask, a then callback on a completed future, and everything after an await all feed the microtask queue. Future(f), Future.delayed including Duration.zero, Timer, input and output, isolate messages, gestures, and frame callbacks all feed the event queue. Both queues feed one event loop running on a single isolate and thread, and the microtask queue drains fully while the event queue gives up one item per pass.

Future constructors compared

scheduleMicrotask(f);        // microtask, returns nothing
Future.microtask(f);         // microtask, returns a Future so you can chain or await
Future.value(x);             // already complete, .then on it is a microtask
Future.sync(f);              // runs f IMMEDIATELY, synchronously
Future(f);                   // event queue, zero duration timer via Timer.run
Future.delayed(d, f);        // event queue, timer of duration d

Future.sync is the useful oddity. It runs now but keeps any thrown error inside the future instead of throwing at the call site.


What await actually does

await suspends the function and turns the rest of the body into a continuation scheduled as a microtask.

Two consequences worth remembering:

// 1. An async function runs SYNCHRONOUSLY up to its first await.
Future<void> f() async {
  print('runs immediately');   // not deferred
  await something;
  print('runs as a microtask');
}

// 2. await on a non-Future STILL suspends.
await null;   // not a no-op, the rest of the function yields
await 5;      // same

Do not try to count exact microtask hops. Implementations have changed across Dart versions. Reason about relative order, which is what is actually being tested.


Concurrency is not parallelism

async changes when code runs, not where. One thread per isolate.

Future<void> heavy() async {
  for (var i = 0; i < 1000000000; i++) {}   // still blocks the isolate
}

await does not move work off the thread. A long synchronous loop inside an async function blocks the UI isolate and drops frames. For CPU bound work you need an isolate or compute(), which get their own thread, heap, and event loop.


Output prediction drills

Classify every line, write three lists (sync, microtask, event), then read the output off in order.


Traps worth memorising

1  Future(() {}) is the EVENT queue, not a microtask
2  await null still suspends
3  Duration.zero is still the event queue
4  returning a Future from .then makes the chain wait for it
5  the microtask queue drains FULLY, including microtasks added mid-drain
6  Future.wait does not cancel the remaining futures on error

Why this matters in Flutter

The event loop is per isolate, and the UI runs on one isolate. The framework's frame callbacks sit on that same loop.

long sync block      → frame callback never runs → dropped frame
unbounded microtasks → event queue starved      → frozen UI

This is the mechanism behind jank. It connects directly to RepaintBoundary in layout-and-rendering and to the profiling advice in performance.


Error handling in async code

An error inside async code does not travel up the call stack. It completes the future with an error instead, which changes where you can catch it and how easily you can lose it.

Future chain

Example:

fetchUser()
  .then((user) {
    return fetchOrders(user);
  })
  .then((orders) {
    print(orders);
  })
  .catchError((error) {
    print(error);
  });

Error handling happens using:

.catchError()

async/await

Cleaner approach:

try {
  var user = await fetchUser();
  var orders = await fetchOrders(user);
  print(orders);
} catch (e) {
  print(e);
}

Side by side

FeatureFuture chainasync/await
Syntaxthen()try/catch
Readabilityhardereasier
Debugginghardereasier
Flowcallback stylesynchronous style

Unhandled errors

A throw inside an async function does not propagate synchronously. It completes the returned future with an error. If nothing awaits or catches it, it becomes an unhandled async error and reaches the zone handler, which in Flutter means FlutterError.onError or PlatformDispatcher.instance.onError.

An error handler attached later still works, because the error is stored on the future. Discard the future entirely and the error is lost to the zone.

Future.wait does not cancel

await Future.wait([a, b, c]);

Completes with a list in input order, not completion order. On error it completes with the first error, and the other futures keep running. Their errors become unhandled.

Future.wait(
  futures,
  eagerError: true,                 // fail as soon as any one fails
  cleanUp: (value) => value.dispose(),  // dispose results that arrive after the error
);

Dart futures have no cancellation. That is the part people miss.

return vs return await in a try block

// error is NOT caught: the try block has finished before the error happens
Future<int> a() async {
  try {
    return riskyFuture();
  } catch (e) {
    return -1;
  }
}

// error IS caught: await keeps the function suspended inside the try
Future<int> b() async {
  try {
    return await riskyFuture();
  } catch (e) {
    return -1;
  }
}

This is the one case where return await is meaningful rather than redundant.

On this page