DEV Community

Ge Ji
Ge Ji

Posted on

Dart Lesson 14: Advanced Asynchronous Programming - Exception Handling and Concurrency

In the previous lesson, we learned the basics of asynchronous programming - Future and async/await, mastering how to write asynchronous code with a synchronous style. But in real-world development, exception handling for asynchronous operations, coordination of multiple asynchronous tasks, and performance optimization are equally important. Today we'll dive into advanced asynchronous content to solve these more complex problems.

I. Asynchronous Exception Handling: In-depth Practice with try-catch + async

Exception handling in asynchronous operations is more complex than in synchronous code because errors may be thrown "at some point in the future". Combining try-catch with async/await is the best practice for handling asynchronous exceptions, but there are some details to note.

1. Basic Catching Method

In an async function, if code after await throws an exception, it will be caught by try-catch, just like in synchronous code:

Future<void> riskyOperation() async {
  await Future.delayed(Duration(seconds: 1));
  // Simulate throwing an exception in an asynchronous operation
  throw Exception("Asynchronous operation failed: network timeout");
}

void main() async {
  try {
    print("Starting risky operation");
    await riskyOperation(); // Wait for asynchronous operation; exceptions will be caught
    print("Operation completed successfully completed (won't execute)");
  } catch (e) {
    // Catch asynchronous exception
    print("Caught exception: ${e.toString()}");
  } finally {
    // Executes regardless of success or failure
    print("Operation finished, cleaning up resources");
  }
}

// Output:
// Starting risky operation
// Caught exception: Exception: Asynchronous operation failed: network timeout
// Operation finished, cleaning up resources
Enter fullscreen mode Exit fullscreen mode

2. Exception Handling in Nested Asynchronous Calls

When an async function contains multiple asynchronous calls, try-catch can catch all nested exceptions:

Future<void> level3() async {
  await Future.delayed(Duration(milliseconds: 500));
  throw Exception("Innermost error"); // Throw exception
}

Future<void> level2() async {
  await level3(); // Call inner asynchronous function
}

Future<void> level1() async {
  await level2(); // Call middle asynchronous function
}

void main() async {
  try {
    await level1(); // Call outer asynchronous function
  } catch (e) {
    // Exceptions from all levels will be caught
    print(
      "Top level caught exception: $e",
    ); // Output: Top level caught exception: Exception: Innermost error
  }
}
Enter fullscreen mode Exit fullscreen mode

This demonstrates the advantage of async/await: exceptions propagate like they do in synchronous code, avoiding nested exception handling in callback hell.

3. Differentiating Between Exception Types

You can catch specific types of exceptions with on Type catch for granular handling:

// Custom exception types
class NetworkException implements Exception {
  final String message;
  NetworkException(this.message);
  @override
  String toString() => "NetworkException: $message";
}

class DatabaseException implements Exception {
  final String message;
  DatabaseException(this.message);
  @override
  String toString() => "DatabaseException: $message";
}

Future<void> fetchData() async {
  // Simulate random exceptions
  await Future.delayed(Duration(seconds: 1));
  if (DateTime.now().second % 2 == 0) {
    throw NetworkException("Server connection failed");
  } else {
    throw DatabaseException("Data query error");
  }
}

void main() async {
  try {
    await fetchData();
  } on NetworkException catch (e) {
    // Only catch NetworkException
    print("Network error handling: $e");
    // Retry logic could go here
  } on DatabaseException catch (e) {
    // Only catch DatabaseException
    print("Database error handling: $e");
    // Data repair logic could go here
  } catch (e) {
    // Catch all other exceptions
    print("Unknown error: $e");
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Dangers of Uncaught Exceptions

If an asynchronous exception isn't caught, it will crash the program (in Flutter, this causes app crashes):

Future<void> unhandledError() async {
  throw Exception("Uncaught exception");
}

void main() {
  unhandledError(); // Call asynchronous function but don't handle exceptions
  print("Program continues executing...");

  // Output:
  // Program continues executing...
  // (Later) Unhandled exception: Exception: Uncaught exception
}
Enter fullscreen mode Exit fullscreen mode

Best Practice: All await calls must be inside try-catch blocks or have error handling via .catchError.


II. Future Combinations: Coordinating Multiple Asynchronous Tasks

Real-world development often requires handling multiple asynchronous tasks. Dart provides utility methods like Future.wait and Future.any to help us flexibly combine multiple Futures.

1. Future.wait: Waiting for All Tasks to Complete (Successfully)

When you need all asynchronous tasks to complete before proceeding (like loading multiple resources in parallel), use Future.wait:

// Simulate three different asynchronous tasks
Future<String> fetchUser() =>
    Future.delayed(Duration(seconds: 1), () => "User data");
Future<String> fetchOrders() =>
    Future.delayed(Duration(seconds: 2), () => "Order data");
Future<String> fetchProducts() =>
    Future.delayed(Duration(seconds: 1), () => "Product data");

void main() async {
  print("Starting parallel data loading...");

  // Wait for all Futures to complete (total time depends on slowest task, 2 seconds here)
  List<String> results = await Future.wait([
    fetchUser(),
    fetchOrders(),
    fetchProducts(),
  ]);

  // Executes only after all tasks complete successfully
  print("All data loaded:");
  print("User: ${results[0]}");
  print("Orders: ${results[1]}");
  print("Products: ${results[2]}");
}

// Output:
// Starting parallel data loading...
// (2-second wait)
// All data loaded:
// User: User data
// Orders: Order data
// Products: Product data
Enter fullscreen mode Exit fullscreen mode

Note: If any task throws an exception in Future.wait, the entire wait immediately fails and throws that exception:

Future<String> fetchSuccess() => Future.value("Success data");
Future<String> fetchFailure() => Future.delayed(Duration(seconds: 1), () {
  throw Exception("One task failed");
});

void main() async {
  try {
    await Future.wait([fetchSuccess(), fetchFailure()]);
  } catch (e) {
    print(
      "Caught exception: $e",
    ); // Output: Caught exception: Exception: One task failed
  }
}
Enter fullscreen mode Exit fullscreen mode

If you need to continue waiting for other tasks even if some fail, add error handling to each individual Future:

Future<String> fetchSuccess() => Future.value("Success data");
Future<String> fetchFailure() => Future.delayed(Duration(seconds: 1), () {
  throw Exception("One task failed");
});

void main() async {
  // Add catchError to each Future to convert errors to normal values
  List<Future<String>> futures = [
    fetchSuccess(),
    fetchFailure().catchError((e) => "Error data: ${e.toString()}"),
  ];

  List<String> results = await Future.wait(futures);
  print(
    results,
  ); // Output: [Success data, Error data: Exception: One task failed]
}
Enter fullscreen mode Exit fullscreen mode

2. Future.any: Waiting for the First Completed Task

When you need only one task to complete among multiple (like downloading from multiple mirror servers and choosing the fastest), use Future.any:

// Simulate tasks with different speeds
Future<String> fastTask() =>
    Future.delayed(Duration(seconds: 1), () => "Fast task result");
Future<String> mediumTask() =>
    Future.delayed(Duration(seconds: 2), () => "Medium task result");
Future<String> slowTask() =>
    Future.delayed(Duration(seconds: 3), () => "Slow task result");

void main() async {
  print("Waiting for first completed task...");

  // Wait only for the first completed task (fastTask completes in 1 second here)
  String firstResult = await Future.any([fastTask(), mediumTask(), slowTask()]);

  print(
    "First completed task result: $firstResult",
  ); // Output: First completed task result: Fast task result
}
Enter fullscreen mode Exit fullscreen mode

Note: The first failing task in a Future.any will cause the entire task to fail, unless that task has been individually handled for errors:

Future<String> fastFailure() => Future.delayed(Duration(seconds: 1), () {
  throw Exception("Fast failure");
});
Future<String> slowSuccess() =>
    Future.delayed(Duration(seconds: 2), () => "Slow success");

void main() async {
  try {
    // The first one to complete is fastFailure, which will cause the entire process to fail.
    await Future.any([fastFailure(), slowSuccess()]);
  } catch (e) {
    print("Caught exception:$e"); // Output: Caught exception: Exception: Fail fast
  }

  // Correct handling: Add error handling for tasks that may fail first
  String result = await Future.any([
    fastFailure().catchError((e) => "error"), // Returns null on error
    slowSuccess(),
  ]);
  print("Final result: $result"); // Output: Final result: error
}
Enter fullscreen mode Exit fullscreen mode

3. Future.forEach: Processing Iterables Sequentially

Future.forEach processes asynchronous operations in sequence (one completes before the next starts), ideal for scenarios requiring serial processing:

void main() async {
  List<int> ids = [1, 2, 3];

  // Process each id sequentially, waiting for one to complete before next
  await Future.forEach(ids, (int id) async {
    await Future.delayed(
      Duration(seconds: 1),
    ); // Simulate time-consuming operation
    print("Processed id: $id");
  });

  print("All ids processed");
}

// Output (one line every second):
// Processed id: 1
// Processed id: 2
// Processed id: 3
// All ids processed
Enter fullscreen mode Exit fullscreen mode

III. Asynchronous Loops and Performance Pitfalls: Avoid Creating Futures in Loops

When handling asynchronous operations in loops, it's easy to fall into performance traps. Incorrect implementations can cause resource waste or inefficiency.

1. Bad Practice: Creating Many Parallel Futures in a Loop

// Simulate a single asynchronous task
Future<void> processItem(int i) async {
  await Future.delayed(Duration(milliseconds: 100));
  // print("Processed item $i");
}

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

  // Bad: Creates 1000 parallel Futures at once
  List<Future> futures = [];
  for (int i = 0; i < 1000; i++) {
    futures.add(processItem(i));
  }
  await Future.wait(futures);

  stopwatch.stop();
  print(
    "Total time: ${stopwatch.elapsedMilliseconds} ms",
  ); // ~100-200 ms (parallel advantage)
}
Enter fullscreen mode Exit fullscreen mode

While parallel processing seems fast, with very large loop counts (like 100,000):

  • It creates many Future objects instantly, consuming significant memory
  • May hit system limits on concurrent connections (for network requests)
  • Causes CPU or I/O resource contention, actually reducing efficiency

2. Improved Practice: Controlling Concurrency

Avoid resource exhaustion by processing in batches or limiting concurrency:

Future<void> processItem(int i) async {
  await Future.delayed(Duration(milliseconds: 100));
  // print("Processed item $i");
}

// Batch processing: process 10 items at a time, wait for batch completion before next
Future<void> processInBatches(List<int> items, int batchSize) async {
  for (int i = 0; i < items.length; i += batchSize) {
    int end = i + batchSize;
    List<int> batch = items.sublist(i, end > items.length ? items.length : end);

    // Process current batch in parallel
    await Future.wait(batch.map((item) => processItem(item)));
    print("Completed batch ${i ~/ batchSize + 1}");
  }
}

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

  // Create 1000 items to process
  List<int> items = List.generate(1000, (index) => index);

  // Process 10 items per batch
  await processInBatches(items, 10);

  stopwatch.stop();
  print(
    "Total time: ${stopwatch.elapsedMilliseconds} ms",
  ); // ~1000 ms (10 batches × 100 ms)
}
Enter fullscreen mode Exit fullscreen mode

While total time increases, this avoids resource contention and system limits, making it suitable for large-scale processing.

3. Correct Sequential Loop: Using await for Control

If tasks must execute sequentially (each depends on the previous result), use await inside the loop:

Future<void> processItem(int i) async {
  await Future.delayed(Duration(milliseconds: 100));
  // print("Processed item $i");
}

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

  // Correct: Execute sequentially, one completes before next starts
  for (int i = 0; i < 5; i++) {
    await processItem(i); // Wait for current task to complete in each iteration
  }

  stopwatch.stop();
  print(
    "Total time: ${stopwatch.elapsedMilliseconds} ms",
  ); // ~500 ms (5 × 100 ms)
}
Enter fullscreen mode Exit fullscreen mode

Note: Don't collect Futures outside the loop then wait - this creates parallel execution:

Future<void> processItem(int i) async {
  await Future.delayed(Duration(milliseconds: 100));
  // print("Processed item $i");
}

// Wrong: Looks sequential but executes in parallel
void main() async {
  List<Future> futures = [];
  for (int i = 0; i < 5; i++) {
    futures.add(processItem(i)); // Creates all Futures immediately
  }
  await Future.wait(futures); // Executes in parallel, total time ~100 ms
}
Enter fullscreen mode Exit fullscreen mode

IV. Debugging Techniques for Asynchronous Code

Debugging asynchronous code is more complex than synchronous code because execution order isn't straightforward. Here are useful techniques:

  1. Use print or logging tools: Print timestamps and variable states at key points to track execution order.
  2. Leverage async function stack traces: Dart records call stacks for asynchronous operations, showing complete paths when exceptions occur:
Future<void> a() async => await b();
Future<void> b() async => await c();
Future<void> c() async => throw Exception("Error occurred");

void main() async {
  try {
    await a();
  } catch (e, stackTrace) {
    print("Exception: $e");
    print(
      "Stack trace: $stackTrace",
    ); // Shows complete path from c → b → a → main
  }
}
Enter fullscreen mode Exit fullscreen mode

3 . Debug with Flutter DevTools: Flutter's DevTools includes a dedicated asynchronous task tracking panel to visually inspect all active Futures.

Top comments (0)