Module 03: Dart Async — Futures, Streams, and the Event Loop

Dart & Widget Basics

Async Dart — doing multiple things without breaking a sweat

🎯

Teach: How asynchronous programming works in Dart — Futures for one-time async values, Streams for ongoing event sequences, and error handling for both. See: Real patterns for fetching data, handling delays, and processing event streams. Feel: Confident that you can write async code without getting lost in callback spaghetti.


Why Async Matters

🎯

Teach: Why asynchronous programming is essential in Flutter and how the event loop works. See: What happens when you block the UI thread vs. handling work asynchronously. Feel: Urgency that async is not optional — it is survival for a responsive app.

Picture this: your app needs to fetch a user's profile from an API. That network request takes 500 milliseconds. If your app waits — just sits there doing nothing for half a second — the UI freezes. No scrolling, no animations, no response to taps. The user thinks the app crashed.

Async programming solves this. Instead of waiting, your app says: "Go fetch that data. I'll keep doing other things. Let me know when you're done."

🎙️

Dart is single-threaded. There's one thread running your code, one thread handling the UI. If you block that thread with a long operation, everything freezes. Async programming isn't a nice-to-have in Flutter — it's survival. Every network call, every file read, every database query must be async.

The Event Loop (Quick Version)

Dart uses an event loop — a continuously running cycle that processes events one at a time:

  1. Pick up the next event from the queue
  2. Execute it
  3. Go back to step 1

When you await a Future, Dart pauses your function (not the whole app!) and goes back to processing other events. When the Future completes, Dart puts "resume that function" back on the event queue.

The result: your UI stays responsive while async work happens in the background.


Futures: Promises of a Value

🎯

Teach: What a Future is, how async/await works, and the three states a Future can be in. See: Async functions that simulate network delays and return values after a pause. Feel: Comfortable reading and writing async/await code without confusion.

A Future<T> represents a value that isn't available yet but will be sometime in the future. It's Dart's equivalent of a JavaScript Promise.

// This function returns a Future — the value isn't ready yet
Future<String> fetchUsername() async {
  // Simulate a network delay
  await Future.delayed(Duration(seconds: 2));
  return 'Campbell';
}

async and await

The async keyword marks a function as asynchronous. The await keyword pauses execution until a Future completes:

Future<void> main() async {
  print('Fetching username...');
  var name = await fetchUsername();
  print('Got it: $name');
  print('Done!');
}

// Output:
// Fetching username...
// (2 second pause)
// Got it: Campbell
// Done!

Without await, you'd get a Future<String> object instead of the actual string. The await unwraps the Future and gives you the value inside.

🎙️

Think of a Future like ordering food at a restaurant. You place the order (call the async function), get a receipt (the Future), and go back to your conversation (other code runs). When the food is ready (the Future completes), the waiter brings it to you (the awaited value arrives). You don't stand at the kitchen window staring until it's done.

What a Future Actually Is

A Future can be in one of three states:

Uncompleted  →  Completed with value  ✓
                Completed with error  ✗
Future<int> divide(int a, int b) async {
  if (b == 0) {
    throw ArgumentError('Cannot divide by zero');
  }
  return a ~/ b;
}

void main() async {
  var result = await divide(10, 2);   // Completes with value: 5
  print(result);

  var bad = await divide(10, 0);      // Completes with error!
}
💡

Every async function returns a Future. Even if you write return 'hello', the actual return type is Future<String>. Dart wraps it automatically.


Future.wait: Parallel Execution

🎯

Teach: How to run multiple Futures in parallel instead of sequentially using Future.wait. See: A side-by-side timing comparison showing parallel execution cutting total wait time. Feel: The performance instinct to fire independent Futures simultaneously.

What if you need to fetch data from three different sources? Don't await them one at a time — run them in parallel with Future.wait:

Future<String> fetchUser() async {
  await Future.delayed(Duration(seconds: 2));
  return 'Campbell';
}

Future<List<String>> fetchPosts() async {
  await Future.delayed(Duration(seconds: 3));
  return ['Post 1', 'Post 2', 'Post 3'];
}

Future<int> fetchFollowerCount() async {
  await Future.delayed(Duration(seconds: 1));
  return 42;
}

void main() async {
  // SLOW — sequential (6 seconds total)
  var user = await fetchUser();
  var posts = await fetchPosts();
  var followers = await fetchFollowerCount();

  // FAST — parallel (3 seconds total, limited by the slowest)
  var results = await Future.wait([
    fetchUser(),
    fetchPosts(),
    fetchFollowerCount(),
  ]);

  print(results[0]);  // Campbell
  print(results[1]);  // [Post 1, Post 2, Post 3]
  print(results[2]);  // 42
}
🎙️

Sequential awaits are the #1 async performance mistake beginners make. If two Futures don't depend on each other, fire them both at once. Future.wait is your friend — it launches all Futures simultaneously and waits for all of them to finish. Total time = slowest one, not sum of all.


Error Handling with try/catch

🎯

Teach: How to handle errors in async code gracefully instead of letting them crash the app. See: The try/catch/finally pattern in action with real error scenarios. Feel: Prepared to handle the messy reality of network failures and bad data.

Async operations fail. Networks go down. Servers return errors. Files don't exist. You must handle these cases:

Future<String> fetchData() async {
  await Future.delayed(Duration(seconds: 1));
  throw Exception('Server is down!');
}

void main() async {
  try {
    var data = await fetchData();
    print('Data: $data');
  } catch (e) {
    print('Error: $e');  // Error: Exception: Server is down!
  } finally {
    print('Cleanup: this always runs');
  }
}

Catching Specific Error Types

Future<int> parseAge(String input) async {
  var age = int.tryParse(input);
  if (age == null) {
    throw FormatException('Invalid age: $input');
  }
  if (age < 0) {
    throw RangeError('Age cannot be negative: $age');
  }
  return age;
}

void main() async {
  try {
    var age = await parseAge('not-a-number');
    print('Age: $age');
  } on FormatException catch (e) {
    print('Format problem: $e');
  } on RangeError catch (e) {
    print('Range problem: $e');
  } catch (e) {
    print('Unknown error: $e');
  }
}

The on Type catch (e) syntax lets you handle different error types differently. The final bare catch is a safety net for anything unexpected.

A Practical Pattern: Retry Logic

Future<String> fetchWithRetry(int maxAttempts) async {
  for (var attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fetchData();
    } catch (e) {
      print('Attempt $attempt failed: $e');
      if (attempt == maxAttempts) rethrow;
      await Future.delayed(Duration(seconds: attempt)); // backoff
    }
  }
  throw StateError('Unreachable');
}
💡

Always wrap async calls in try/catch. In Flutter, an unhandled error in an async function can crash the entire app or leave the UI in a broken state.

🎙️

Here's a rule I want you to internalize: every await you write is a possible failure point. The network call can timeout. The JSON can be malformed. The file can be missing. The server can return a 500. If you skip error handling, these failures don't just print a warning — they throw uncaught exceptions that ripple up through your widgets and either crash the app or leave the screen in a broken half-state. Wrap async calls in try/catch, catch specific types when you can handle them differently, and always ask yourself "what does the user see when this fails?" A good Flutter app fails gracefully — a bad one just freezes.


Streams: Async Data Over Time

🎯

Teach: What Streams are, how they differ from Futures, and how to create and consume them. See: An async* generator yielding values over time, consumed with await for and .listen(). Feel: The click of understanding that Streams are just Futures that keep on giving.

A Future gives you one value in the future. A Stream gives you many values over time.

Think of the difference: - Future: "What's the current temperature?" — one answer - Stream: "Tell me the temperature every 5 seconds" — ongoing answers

// A stream that emits numbers 0-4 with a delay between each
Stream<int> countStream(int max) async* {
  for (var i = 0; i < max; i++) {
    await Future.delayed(Duration(seconds: 1));
    yield i;  // "yield" sends a value into the stream
  }
}

void main() async {
  print('Counting...');
  await for (var count in countStream(5)) {
    print('Count: $count');
  }
  print('Done!');
}

// Output:
// Counting...
// Count: 0  (after 1 sec)
// Count: 1  (after 2 sec)
// Count: 2  (after 3 sec)
// Count: 3  (after 4 sec)
// Count: 4  (after 5 sec)
// Done!

The async* and yield Keywords

  • async* marks a function as an async generator — it returns a Stream
  • yield sends a single value into the stream
  • yield* forwards all values from another stream
Stream<String> greetings() async* {
  yield 'Hello';
  await Future.delayed(Duration(seconds: 1));
  yield 'Bonjour';
  await Future.delayed(Duration(seconds: 1));
  yield 'Hola';
}
🎙️

Streams are everywhere in Flutter. User input? Stream of tap events. Firebase data? Stream of database changes. WebSockets? Stream of messages. Bluetooth devices? Stream of sensor readings. Once you understand Streams, a huge chunk of Flutter's API clicks into place.

Listening to Streams

You have two ways to consume a stream:

await for — process each value sequentially:

await for (var greeting in greetings()) {
  print(greeting);
}

.listen() — set up a callback:

greetings().listen(
  (greeting) => print(greeting),
  onError: (error) => print('Error: $error'),
  onDone: () => print('Stream closed'),
);

Stream Transformations

Streams support the same functional operations as lists:

Stream<int> numbers() async* {
  for (var i = 1; i <= 10; i++) {
    yield i;
  }
}

void main() async {
  // Filter: only even numbers
  await for (var n in numbers().where((n) => n % 2 == 0)) {
    print(n);  // 2, 4, 6, 8, 10
  }

  // Map: double each value
  await for (var n in numbers().map((n) => n * 2)) {
    print(n);  // 2, 4, 6, ..., 20
  }

  // Take: only first 3 values
  await for (var n in numbers().take(3)) {
    print(n);  // 1, 2, 3
  }
}

StreamController: Creating Custom Streams

🎯

Teach: How to create streams you control manually using StreamController and broadcast streams. See: A custom event bus pattern with multiple listeners reacting to the same stream. Feel: Capable of building your own reactive data pipelines from scratch.

Sometimes you need to create a stream that you can add values to from outside — not from an async* generator. That's what StreamController is for:

import 'dart:async';

void main() async {
  // Create a controller
  var controller = StreamController<String>();

  // Listen to the stream
  controller.stream.listen(
    (message) => print('Received: $message'),
    onDone: () => print('Stream closed'),
  );

  // Add values from outside
  controller.add('Hello');
  controller.add('World');
  controller.add('!');

  // Close the stream when done
  await controller.close();
}

A Practical Example: Event Bus

import 'dart:async';

class EventBus {
  final _controller = StreamController<String>.broadcast();

  Stream<String> get events => _controller.stream;

  void fire(String event) => _controller.add(event);

  void dispose() => _controller.close();
}

void main() async {
  var bus = EventBus();

  // Multiple listeners (broadcast stream)
  bus.events.listen((e) => print('Listener 1: $e'));
  bus.events.listen((e) => print('Listener 2: $e'));

  bus.fire('user_logged_in');
  bus.fire('data_loaded');

  await Future.delayed(Duration(milliseconds: 100));
  bus.dispose();
}
🎙️

Notice the .broadcast() — a regular StreamController allows only one listener. A broadcast StreamController allows many. In Flutter, you'll typically use broadcast streams when multiple widgets need to react to the same events.

The key difference: - Single-subscription (default): One listener. Buffered — values wait for a listener. - Broadcast: Multiple listeners. Not buffered — values are lost if no one's listening.


There Are No Dumb Questions

🎯

Teach: Answers to common questions about async, await, Futures, and Streams. See: Clear distinctions between easily confused concepts like async vs. async*. Feel: Reassured that these are normal stumbling points with simple explanations.

Q: What's the difference between async and async*? A: async makes a function return a Future<T> — one value in the future. async* makes a function return a Stream<T> — many values over time. Use return in async functions, yield in async* functions.

Q: Can I use await outside an async function? A: No. The await keyword can only be used inside an async function. If you need to await in main(), make it Future<void> main() async { ... }.

Q: What happens if I never await a Future? A: The async operation still runs, but you won't get its result. Worse, if it throws an error, that error goes unhandled. The Dart analyzer will warn you about unawaited Futures. Always either await them or explicitly call .ignore() if you truly don't care.

Q: When should I use Streams vs. Futures? A: Use a Future when you're getting one value (an API response, a file read, a computation). Use a Stream when values come over time (user events, real-time data, periodic updates). If it happens once, Future. If it happens repeatedly, Stream.

Q: Does Future.wait fail if any single Future fails? A: By default, yes. If one Future throws, Future.wait throws that error and you lose the results. You can pass eagerError: false to collect all results (and errors), but usually you want to know immediately if something failed.

🎙️

The async versus async* distinction is one of those "one tiny character, completely different meaning" moments in Dart. The asterisk means "this function yields many values over time" — it's a generator. Without it, you're producing a single Future. Keep that in mind when you're reading other people's code and you see the little star. On the "unawaited Future" question, take the warning seriously. An unawaited Future that throws is a silent bug — you won't see a crash, you'll just see weird behavior that's almost impossible to diagnose. If you really don't care about the result, write .ignore() to make your intent explicit.


Sharpen Your Pencil

🎯

Teach: How to apply Futures, Streams, and error handling through hands-on async exercises. See: Simulated real-world patterns like parallel data fetching, retry logic, and stream processing. Feel: Prepared to write the async data-loading code that every Flutter app needs.

🔄

Where this fits: Flutter's entire data layer runs on async. Fetching data from APIs, reading from databases, listening to real-time updates — all Futures and Streams. Master these now and you won't struggle later.

Exercise 1: futures.dart

Build a simulated coffee order system:

  1. Write grindBeans() that takes 2 seconds and returns 'Ground beans ready'
  2. Write heatWater() that takes 3 seconds and returns 'Water at 96°C'
  3. Write brew(String beans, String water) that takes 4 seconds and returns 'Coffee brewed!'
  4. First, call them sequentially and measure total time (should be ~9 seconds)
  5. Then, run grindBeans() and heatWater() in parallel with Future.wait, then brew — measure time (should be ~7 seconds)
  6. Add error handling: make heatWater() sometimes throw an error, and handle it gracefully
Future<String> grindBeans() async {
  print('Grinding beans...');
  await Future.delayed(Duration(seconds: 2));
  return 'Ground beans ready';
}

Future<String> heatWater() async {
  print('Heating water...');
  await Future.delayed(Duration(seconds: 3));
  return 'Water at 96°C';
}

Future<String> brew(String beans, String water) async {
  print('Brewing with $beans and $water...');
  await Future.delayed(Duration(seconds: 4));
  return 'Coffee brewed!';
}

void main() async {
  var stopwatch = Stopwatch()..start();

  // Parallel prep, then brew
  var results = await Future.wait([grindBeans(), heatWater()]);
  var coffee = await brew(results[0], results[1]);

  stopwatch.stop();
  print('$coffee (took ${stopwatch.elapsed.inSeconds}s)');
}

Exercise 2: streams.dart

  1. Write an async* function ticker(int seconds) that yields the current count every second
  2. Write a Stream<String> function stockPrices() that yields random price changes every 500ms
  3. Use .where() to filter only price increases
  4. Use .take() to stop after 5 values
  5. Create a StreamController<String> chat system where you can add() messages and listen() for them

Exercise 3: practical_async.dart

Build a simulated data loader that demonstrates real-world patterns:

  1. Write fetchUserProfile(int id) that returns a Future<Map<String, dynamic>>
  2. Write fetchUserPosts(int userId) that returns a Future<List<String>>
  3. Write loadDashboard(int userId) that fetches both in parallel using Future.wait
  4. Wrap everything in try/catch with meaningful error messages
  5. Add a finally block that prints "Loading complete" regardless of success or failure
Future<Map<String, dynamic>> fetchUserProfile(int id) async {
  await Future.delayed(Duration(seconds: 1));
  if (id <= 0) throw ArgumentError('Invalid user ID: $id');
  return {'id': id, 'name': 'Campbell', 'email': 'campbell@example.com'};
}

Future<List<String>> fetchUserPosts(int userId) async {
  await Future.delayed(Duration(seconds: 2));
  return ['First post', 'Learning Dart', 'Async is cool'];
}

Future<void> loadDashboard(int userId) async {
  try {
    var results = await Future.wait([
      fetchUserProfile(userId),
      fetchUserPosts(userId),
    ]);
    var profile = results[0] as Map<String, dynamic>;
    var posts = results[1] as List<String>;
    print('Welcome, ${profile['name']}!');
    print('Your posts: $posts');
  } catch (e) {
    print('Failed to load dashboard: $e');
  } finally {
    print('Loading complete');
  }
}

void main() async {
  await loadDashboard(1);    // Success
  await loadDashboard(-1);   // Error handled gracefully
}
🎙️

That loadDashboard pattern — parallel fetch, try/catch, finally — is exactly what you'll write in Flutter when building screens that load data. Memorize this shape. You'll use it dozens of times.


📝 Module Quiz

Test what you learned. 45 seconds per question. Passing score is set per module. Your best attempt is saved in your browser so you can track progress -- nothing is sent to a server.

What's Next?

🎯

Teach: What the next module covers and how it connects async Dart to visible Flutter UIs. See: A preview of widgets, StatelessWidget, and building real screens. Feel: Excited to finally turn all your Dart knowledge into pixels on screen.

You now have Dart's full async toolkit: Futures for one-shot operations, Streams for ongoing data, Future.wait for parallelism, and try/catch for error handling. In Module 04, we finally return to Flutter and start building real UIs with widgets — the fundamental building blocks of every Flutter app.

🎙️

Three modules of Dart are officially behind you. I know it felt like a detour, but here's the payoff: everything we do from Module 04 forward is just Dart applied to a specific problem — drawing UI. When we reach Module 13 and make HTTP calls, you'll recognize the async patterns. When we build a shopping cart model in Module 12, you'll see classes and inheritance at work. The Dart foundation makes Flutter feel obvious. Next module, your first real widgets.

1 / 1