Dart Advanced Features

Asynchronous Programming: Futures

50 min Lesson 1 of 16

What Is Asynchronous Programming?

In everyday programming, most code runs synchronously -- each line waits for the previous one to finish before executing. But what happens when your program needs to fetch data from a server, read a large file, or wait for a timer? If everything ran synchronously, your entire application would freeze while waiting.

Asynchronous programming allows your program to start a long-running operation and continue executing other code while waiting for the result. When the operation completes, your program is notified and can process the result. This is essential for building responsive apps, especially in Flutter where the UI must remain smooth at all times.

Note: Dart is a single-threaded language. Unlike Java or C++, it does not use multiple threads for concurrency. Instead, it uses an event loop to manage asynchronous operations on a single thread. This makes async programming in Dart simpler and avoids common multi-threading bugs like race conditions and deadlocks.

The Dart Event Loop

At the heart of Dart’s asynchronous system is the event loop. Understanding it is key to writing correct async code.

The event loop works like this:

  1. Dart executes all synchronous code in the main() function first
  2. It then checks the microtask queue (high-priority tasks like Future callbacks)
  3. If the microtask queue is empty, it checks the event queue (I/O events, timers, user input)
  4. It processes one event at a time, then checks the microtask queue again
  5. This cycle repeats until both queues are empty

Event Loop in Action

void main() {
  print('1. Start of main');

  Future(() => print('4. Event queue task'));

  Future.microtask(() => print('3. Microtask queue'));

  print('2. End of main');
}

// Output:
// 1. Start of main
// 2. End of main
// 3. Microtask queue
// 4. Event queue task
Tip: Synchronous code always runs first, then microtasks, then event queue tasks. This ordering is predictable and important to understand when debugging async code.

The Future Class

A Future<T> represents a value that will be available sometime in the future. It is Dart’s core class for handling single asynchronous operations. A Future can be in one of three states:

  • Uncompleted -- the operation is still running
  • Completed with a value -- the operation succeeded and returned a result of type T
  • Completed with an error -- the operation failed

Creating a Basic Future

void main() {
  // A Future that completes after the current event loop iteration
  Future(() {
    print('This runs asynchronously');
    return 42;
  });

  print('This runs first (synchronous)');
}

// Output:
// This runs first (synchronous)
// This runs asynchronously

Future.delayed

The Future.delayed constructor creates a Future that completes after a specified duration. This is extremely useful for simulating network requests during development, implementing timeouts, and creating timed operations.

Using Future.delayed

void main() {
  print('Fetching data...');

  Future.delayed(Duration(seconds: 2), () {
    return 'Data loaded successfully!';
  }).then((value) {
    print(value);
  });

  print('Doing other work while waiting...');
}

// Output:
// Fetching data...
// Doing other work while waiting...
// (2 seconds later)
// Data loaded successfully!

Simulating a Network Request

Future<Map<String, dynamic>> fetchUser(int id) {
  // Simulates a 1.5 second API call
  return Future.delayed(Duration(milliseconds: 1500), () {
    return {
      'id': id,
      'name': 'Ahmed Al-Farsi',
      'email': 'ahmed@example.com',
      'role': 'developer',
    };
  });
}

void main() {
  print('Requesting user data...');

  fetchUser(101).then((user) {
    print('User: ${user["name"]} (${user["email"]})');
  });

  print('Request sent, continuing...');
}

Future.value and Future.error

Future.value creates a Future that completes immediately with a given value. Future.error creates a Future that completes immediately with an error. These are useful for returning cached results or known errors from functions that must return a Future.

Future.value -- Immediate Completion

Future<String> getCachedData(String key) {
  final cache = {
    'user': 'Ahmed',
    'theme': 'dark',
    'lang': 'en',
  };

  if (cache.containsKey(key)) {
    // Return immediately from cache
    return Future.value(cache[key]!);
  }

  // Simulate network fetch for uncached keys
  return Future.delayed(Duration(seconds: 1), () {
    return 'fetched_$key';
  });
}

void main() {
  getCachedData('user').then((v) => print('Cached: $v'));
  getCachedData('avatar').then((v) => print('Fetched: $v'));
}

Future.error -- Immediate Error

Future<int> divide(int a, int b) {
  if (b == 0) {
    return Future.error(ArgumentError('Cannot divide by zero'));
  }
  return Future.value(a ~/ b);
}

void main() {
  divide(10, 2).then((result) {
    print('Result: $result');  // Result: 5
  });

  divide(10, 0).catchError((error) {
    print('Error: $error');  // Error: Invalid argument(s): Cannot divide by zero
  });
}
Warning: Even though Future.value completes "immediately", its .then() callback still runs asynchronously (in the next microtask). It does not execute synchronously inline. This is a common source of confusion for beginners.

Chaining Futures: .then(), .catchError(), .whenComplete()

Futures support a powerful chaining API that lets you build pipelines of asynchronous operations:

  • .then() -- runs when the Future completes successfully; can return a new value or Future
  • .catchError() -- runs when the Future completes with an error
  • .whenComplete() -- runs regardless of success or failure (like finally in try-catch)

Chaining .then() Calls

Future<String> fetchUserId() {
  return Future.delayed(Duration(seconds: 1), () => 'user_42');
}

Future<Map<String, String>> fetchProfile(String userId) {
  return Future.delayed(Duration(milliseconds: 800), () => {
    'id': userId,
    'name': 'Sara Khan',
    'city': 'Dubai',
  });
}

Future<String> fetchAvatar(String userId) {
  return Future.delayed(Duration(milliseconds: 500), () {
    return 'https://avatars.example.com/$userId.png';
  });
}

void main() {
  fetchUserId()
      .then((userId) {
        print('Got user ID: $userId');
        return fetchProfile(userId);
      })
      .then((profile) {
        print('Got profile: ${profile["name"]} from ${profile["city"]}');
        return fetchAvatar(profile['id']!);
      })
      .then((avatarUrl) {
        print('Avatar URL: $avatarUrl');
      })
      .catchError((error) {
        print('Something went wrong: $error');
      })
      .whenComplete(() {
        print('All operations finished.');
      });
}
Note: Each .then() can return either a plain value or another Future. If it returns a Future, the next .then() in the chain waits for that Future to complete before running. This is how you sequence dependent async operations.

Error Handling with .catchError()

The .catchError() method catches errors from any point earlier in the chain. You can also filter errors by type using the optional test parameter.

Selective Error Handling

Future<String> riskyOperation() {
  return Future.delayed(Duration(seconds: 1), () {
    throw FormatException('Invalid data format');
  });
}

void main() {
  riskyOperation()
      .then((value) => print('Success: $value'))
      .catchError(
        (error) => print('Format error: $error'),
        test: (error) => error is FormatException,
      )
      .catchError(
        (error) => print('Unknown error: $error'),
      );
}

Future.wait -- Parallel Execution

Future.wait takes a list of Futures and returns a single Future that completes when all of them have completed. The result is a list of all their values. If any Future in the list fails, the combined Future fails.

Running Multiple Futures in Parallel

Future<String> fetchUser() =>
    Future.delayed(Duration(seconds: 2), () => 'Ahmed');

Future<List<String>> fetchPosts() =>
    Future.delayed(Duration(seconds: 3), () => ['Post 1', 'Post 2']);

Future<int> fetchNotifications() =>
    Future.delayed(Duration(seconds: 1), () => 5);

void main() {
  final stopwatch = Stopwatch()..start();

  Future.wait([
    fetchUser(),
    fetchPosts(),
    fetchNotifications(),
  ]).then((results) {
    print('User: ${results[0]}');
    print('Posts: ${results[1]}');
    print('Notifications: ${results[2]}');
    print('Total time: ${stopwatch.elapsedMilliseconds}ms');
    // ~3000ms, not 6000ms -- they ran in parallel!
  }).catchError((error) {
    print('One of the operations failed: $error');
  });
}
Tip: Use Future.wait whenever you have multiple independent async operations. Running them in parallel instead of sequentially can dramatically reduce total wait time. In the example above, total time is ~3 seconds (the slowest operation) instead of ~6 seconds (sum of all operations).

Future.any -- First to Complete

Future.any returns the result of the first Future to complete successfully. This is useful for racing multiple data sources or implementing timeout patterns.

Racing Multiple Data Sources

Future<String> fetchFromCDN1() =>
    Future.delayed(Duration(seconds: 3), () => 'Data from CDN 1');

Future<String> fetchFromCDN2() =>
    Future.delayed(Duration(seconds: 1), () => 'Data from CDN 2');

Future<String> fetchFromCDN3() =>
    Future.delayed(Duration(seconds: 2), () => 'Data from CDN 3');

void main() {
  Future.any([
    fetchFromCDN1(),
    fetchFromCDN2(),
    fetchFromCDN3(),
  ]).then((fastest) {
    print(fastest);  // Data from CDN 2 (completed first)
  });
}

Implementing a Timeout with Future.any

Future<String> fetchWithTimeout(Future<String> operation, Duration timeout) {
  return Future.any([
    operation,
    Future.delayed(timeout, () => throw TimeoutException(
      'Operation timed out after ${timeout.inSeconds}s',
    )),
  ]);
}

void main() {
  final slowRequest = Future.delayed(
    Duration(seconds: 10),
    () => 'Slow data',
  );

  fetchWithTimeout(slowRequest, Duration(seconds: 3))
      .then((data) => print('Got data: $data'))
      .catchError((error) => print('Error: $error'));
  // Error: TimeoutException after 3s
}

Practical Example: Loading App Data

Let’s build a realistic example that simulates loading data for a mobile app’s home screen. This combines multiple Future patterns you’ve learned.

App Data Loader

import 'dart:math';

// Simulated API functions
Future<Map<String, dynamic>> fetchUserProfile() {
  return Future.delayed(Duration(milliseconds: 800), () {
    return {'name': 'Layla Mahmoud', 'avatar': 'layla.png'};
  });
}

Future<List<String>> fetchRecentOrders() {
  return Future.delayed(Duration(milliseconds: 1200), () {
    return ['Order #1001', 'Order #1002', 'Order #1003'];
  });
}

Future<List<String>> fetchRecommendations() {
  return Future.delayed(Duration(milliseconds: 600), () {
    if (Random().nextBool()) {
      throw Exception('Recommendation service unavailable');
    }
    return ['Product A', 'Product B', 'Product C'];
  });
}

Future<int> fetchCartCount() {
  return Future.delayed(Duration(milliseconds: 300), () => 3);
}

void main() {
  print('Loading home screen data...');
  final stopwatch = Stopwatch()..start();

  // Fetch critical data in parallel
  Future.wait([
    fetchUserProfile(),
    fetchRecentOrders(),
    fetchCartCount(),
  ]).then((results) {
    final profile = results[0] as Map<String, dynamic>;
    final orders = results[1] as List<String>;
    final cartCount = results[2] as int;

    print('\nWelcome, ${profile["name"]}!');
    print('Recent orders: ${orders.join(", ")}');
    print('Cart items: $cartCount');

    // Fetch non-critical data separately (may fail gracefully)
    return fetchRecommendations()
        .then((recs) {
          print('Recommended: ${recs.join(", ")}');
        })
        .catchError((error) {
          print('Recommendations unavailable (showing defaults)');
        });
  }).whenComplete(() {
    print('\nHome screen loaded in ${stopwatch.elapsedMilliseconds}ms');
  });
}
Note: This pattern of loading critical data with Future.wait and non-critical data with graceful error handling is very common in real Flutter apps. The user sees the main content quickly while optional features load or fall back to defaults.
Warning: Never ignore errors from Futures. An unhandled Future error will be reported to the current Zone’s error handler, and in debug mode Dart will print a warning. Always add .catchError() or wrap your async code in try-catch (with async/await, covered in the next lesson).

Summary

In this lesson, you learned the fundamentals of asynchronous programming in Dart:

  • Event Loop -- Dart’s single-threaded mechanism for handling async operations
  • Future -- represents a value that will be available later
  • Future.delayed -- creates a Future that completes after a duration
  • Future.value / Future.error -- creates immediately completed Futures
  • .then() / .catchError() / .whenComplete() -- chaining and error handling
  • Future.wait -- runs multiple Futures in parallel
  • Future.any -- returns the first Future to complete

In the next lesson, you’ll learn about async/await -- a cleaner syntax for working with Futures that makes async code look and feel like synchronous code.