Asynchronous Programming in Dart with the async Package

While Dart’s built-in async and await simplify handling asynchronous operations, the async package provides additional tools to manage Futures, Streams, and cancellable operations more effectively.
Asynchronous Programming in Dart with the async Package
async package: asynchronous programming dart

Asynchronous programming is essential in Dart, allowing developers to write non-blocking code that efficiently handles tasks like network requests, file I/O, and database queries. While Dart’s built-in async and await simplify handling asynchronous operations, the async package provides additional tools to manage Futures, Streams, and cancellable operations more effectively.

In this blog, we’ll dive deep into asynchronous programming in Dart and explore how the async package can help you write cleaner, more efficient code. Whether you’re a beginner looking to understand the basics or an experienced developer seeking advanced techniques, this guide will equip you with the knowledge and practical examples to master asynchronous programming in Dart. 

Table of Contents

What is Asynchronous Programming in Dart

Asynchronous programming is a cornerstone of modern app development, enabling applications to perform tasks like network requests, file I/O, or database operations without blocking the main thread. In Dart, asynchronous programming is built around the concepts of Futures and Streams, which allow developers to handle tasks that take time to complete efficiently. Let’s break down the key concepts and how Dart manages asynchronous operations.

1. Synchronous vs. Asynchronous Code

  • Synchronous Code: Executes line by line, blocking further execution until the current task is complete. For example:
void main() {
  print('Task 1');
  print('Task 2'); // Waits for Task 1 to complete
  print('Task 3'); // Waits for Task 2 to complete
}

Output:

Task 1
Task 2
Task 3
  • Asynchronous Code: Allows tasks to run in the background, freeing up the main thread to handle other operations. For example:
void main() {
  print('Task 1');
  Future.delayed(Duration(seconds: 2), () => print('Task 2')); // Runs in the background
  print('Task 3'); // Doesn't wait for Task 2 to complete
}

Output:

Task 1
Task 3
Task 2 (after 2 seconds)

2. Dart’s Event Loop

Dart uses a single-threaded event loop to manage asynchronous operations. Here’s how it works:

  • The event loop continuously checks the event queue for tasks.
  • When an asynchronous operation (like a network request) is initiated, it’s added to the event queue.
  • Once the main thread is free, the event loop picks up the next task from the queue and executes it.

This model ensures that time-consuming tasks don’t block the main thread, keeping the app responsive.

3. Key Concepts: Futures and Streams

  • Futures: Represent a value or error that will be available at some point in the future. They are used for single asynchronous operations.
Future<String> fetchData() async {
  await Future.delayed(Duration(seconds: 2)); // Simulate a network request
  return 'Data fetched!';
}

void main() async {
  print('Fetching data...');
  String data = await fetchData(); // Waits for the Future to complete
  print(data);
}

Output:

Fetching data...
Data fetched! (after 2 seconds)
  • Streams: Handle sequences of asynchronous events, such as real-time data updates or user input.
Stream<int> countStream() async* {
  for (int i = 1; i <= 5; i++) {
    await Future.delayed(Duration(seconds: 1)); // Simulate a delay
    yield i; // Emit a value
  }
}

void main() async {
  await for (int value in countStream()) {
    print('Value: $value');
  }
}

Output:

Value: 1 (after 1 second)
Value: 2 (after 2 seconds)
Value: 3 (after 3 seconds)
Value: 4 (after 4 seconds)
Value: 5 (after 5 seconds)

4. Why Asynchronous Programming Matters in Dart

  • Improved Performance: Keeps the app responsive by offloading time-consuming tasks.
  • Better User Experience: Prevents UI freezes during operations like loading data or processing files.
  • Scalability: Enables handling multiple tasks concurrently, such as managing multiple network requests or real-time data streams.

The async Package in Dart

While Dart’s built-in async and await keywords make asynchronous programming easier, the async package provides additional tools to handle complex async operations more efficiently. It offers utilities for managing multiple Futures, controlling Streams, and canceling long-running operations, making it an essential tool for developers working with concurrency in Dart.

Installing the async Package

To use the async package, add it to your pubspec.yaml file:

dependencies:
  async: ^2.12.0  

Then, import it into your Dart project:

import 'package:async/async.dart';

When to Use the async Package

Use the async package when:
1. You need to manage multiple async operations efficiently.
2. You want to pause, resume, or control Stream data processing.
3. You need the ability to cancel long-running tasks.
4. You are working with real-time data streaming.

Working with async and await

One of the most powerful features of Dart’s asynchronous programming model is the async and await keywords. These keywords make it easy to write asynchronous code that looks and behaves like synchronous code, improving readability and maintainability. In this section, we’ll explore how to use async and await effectively, along with best practices and examples.

Understanding async and await

  • async: Used to mark a function as asynchronous, meaning it will return a Future.
  • await: Pauses execution until the asynchronous operation completes, then resumes with the result.

Example: Fetching Data with async and await

Future<String> fetchData() async {
  await Future.delayed(Duration(seconds: 2));  
  return 'Data Loaded';
}

void main() async {
  print('Fetching data...');
  String data = await fetchData();
  print(data);
}

How it works:

  1. fetchData() is an async function that returns a Future<String>.
  2. The await keyword pauses execution until fetchData() completes.
  3. Once resolved, the result is stored in data and printed.

Error Handling with try-catch Blocks

When working with asynchronous code, it’s important to handle errors gracefully. Dart allows you to use try-catch blocks with await to catch exceptions that may occur during asynchronous operations.

Future<String> fetchData() async {
  await Future.delayed(Duration(seconds: 2));
  throw Exception('Failed to fetch data'); // Simulate an error
}

void main() async {
  print('Fetching data...');
  try {
    String data = await fetchData();
    print(data);
  } catch (e) {
    print('Error: $e'); // Handle the error
  }
}

Key Points:

  • Use try-catch to handle exceptions in asynchronous code.
  • You can also use on to catch specific types of exceptions.

Chaining Asynchronous Operations

async and await make it easy to chain multiple asynchronous operations in a readable way. Instead of nesting callbacks, you can write sequential code that waits for each operation to complete.

Future<String> fetchUserData() async {
  await Future.delayed(Duration(seconds: 2)); // Simulate a network request
  return 'User Data';
}

Future<String> fetchProductData(String userData) async {
  await Future.delayed(Duration(seconds: 2)); // Simulate a network request
  return 'Product Data for $userData';
}

void main() async {
  print('Fetching user data...');
  String userData = await fetchUserData();
  print('Fetching product data...');
  String productData = await fetchProductData(userData);
  print(productData);
}

Running Asynchronous Operations Concurrently

While await is great for sequential operations, sometimes you may want to run multiple asynchronous tasks concurrently. Dart provides tools like Future.wait to achieve this.

Future<String> fetchUserData() async {
  await Future.delayed(Duration(seconds: 2)); // Simulate a network request
  return 'User Data';
}

Future<String> fetchProductData() async {
  await Future.delayed(Duration(seconds: 3)); // Simulate a network request
  return 'Product Data';
}

void main() async {
  print('Fetching data concurrently...');
  List<String> results = await Future.wait([fetchUserData(), fetchProductData()]);
  print('Results: $results');
}

Key Points:

  • Use Future.wait to run multiple Futures concurrently.
  • The result is a list of values in the same order as the input Futures.

Practical Use Cases of the async Package

The async package extends Dart’s built-in asynchronous capabilities, making it easier to manage Futures, Streams, and long-running tasks efficiently. Here are some real-world scenarios where the package proves invaluable.

1. Handling Multiple API Calls Efficiently

In applications that require fetching multiple API responses simultaneously—such as loading user data and recent posts—the FutureGroup class helps manage multiple async operations as a single unit.

Example: Fetching User and Post Data in Parallel

import 'package:async/async.dart';

Future<String> fetchUser() async {
  return Future.delayed(Duration(seconds: 2), () => 'User Data');
}

Future<String> fetchPosts() async {
  return Future.delayed(Duration(seconds: 3), () => 'Posts Data');
}

void main() async {
  final futureGroup = FutureGroup<String>();

  futureGroup.add(fetchUser());
  futureGroup.add(fetchPosts());
  futureGroup.close();

  final results = await futureGroup.future;
  print(results); // ['User Data', 'Posts Data']
}

FutureGroup ensures that both API calls run concurrently, improving response time.

2. Managing Real-Time Data Streams

Many applications, like chat apps or live dashboards, rely on continuous data streams. The StreamQueue class helps process stream data sequentially and efficiently.

Example: Processing Real-Time Messages One by One

import 'package:async/async.dart';

Stream<String> messageStream() async* {
  await Future.delayed(Duration(seconds: 1));
  yield 'Hello!';
  await Future.delayed(Duration(seconds: 2));
  yield 'How are you?';
}

void main() async {
  final queue = StreamQueue(messageStream());

  while (await queue.hasNext) {
    print('New Message: ${await queue.next}');
  }
}

StreamQueue ensures that each message is processed in order without missing any events.

3. Implementing Cancellable Background Tasks

Long-running operations, such as file downloads or background tasks, may need to be stopped when the user exits the app. The CancelableOperation class provides an easy way to cancel tasks when they’re no longer needed.

Example: Canceling a File Download

import 'package:async/async.dart';

Future<String> downloadFile() async {
  return Future.delayed(Duration(seconds: 5), () => 'File Downloaded');
}

void main() async {
  final operation = CancelableOperation.fromFuture(downloadFile());

  Future.delayed(Duration(seconds: 2), () {
    operation.cancel(); // Cancel download after 2 seconds
    print('Download canceled!');
  });

  try {
    final result = await operation.value;
    print(result);
  } catch (e) {
    print('Operation failed: $e');
  }
}

The download operation is stopped before it completes, preventing unnecessary resource usage.

4. Merging Multiple Data Streams

When working with multiple real-time data sources, like combining notifications and chat messages, StreamGroup helps unify them into a single stream.

Example: Merging Chat and Notification Streams

import 'package:async/async.dart';

Stream<String> chatStream() async* {
  await Future.delayed(Duration(seconds: 1));
  yield 'New Chat Message';
}

Stream<String> notificationStream() async* {
  await Future.delayed(Duration(seconds: 2));
  yield 'New Notification';
}

void main() async {
  final streamGroup = StreamGroup<String>();
  streamGroup.add(chatStream());
  streamGroup.add(notificationStream());
  streamGroup.close();

  await for (final value in streamGroup.stream) {
    print('Received: $value');
  }
}

This allows different event streams to be processed together, improving app responsiveness.

5. Batch Processing Asynchronous Tasks

Sometimes, you need to limit the number of concurrent tasks to avoid overloading system resources. The StreamQueue class helps control batch execution of async operations.

Example: Processing API Calls in Batches

import 'package:async/async.dart';

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

void main() async {
  final queue = StreamQueue(requestStream());

  while (await queue.hasNext) {
    int requestId = await queue.next;
    print('Processing request: $requestId');
  }
}

This ensures that each request is processed in a controlled manner, rather than overwhelming the system with too many concurrent requests.

Testing Asynchronous Code in Dart

Testing asynchronous code in Dart requires special handling because tests need to wait for async operations to complete before verifying results. The test package provides built-in support for writing and executing asynchronous tests effectively.

1. Writing Basic Async Tests

To test async functions, use test and mark the test function with async. The await keyword ensures the test waits for the operation to complete before making assertions.

Example: Testing an Async Function

import 'package:test/test.dart';

Future<String> fetchData() async {
  await Future.delayed(Duration(seconds: 1));
  return 'Hello, Dart!';
}

void main() {
  test('fetchData returns expected value', () async {
    String result = await fetchData();
    expect(result, equals('Hello, Dart!'));
  });
}

The await keyword ensures fetchData() completes before expect() verifies the result.

2. Testing Async Code with Timeouts

If a test depends on an operation that could take too long, setting a timeout prevents tests from hanging indefinitely.

Example: Setting a Timeout for Async Tests

test('fetchData completes within 2 seconds', () async {
  expect(fetchData().timeout(Duration(seconds: 2)), completes);
});

If fetchData() takes longer than 2 seconds, the test fails, ensuring that performance expectations are met.

3. Testing Streams with expectLater()

When testing Streams, expectLater() helps verify multiple emitted values.

Example: Testing a Stream with Multiple Events

Stream<int> numberStream() async* {
  yield 1;
  await Future.delayed(Duration(milliseconds: 500));
  yield 2;
  await Future.delayed(Duration(milliseconds: 500));
  yield 3;
}

void main() {
  test('numberStream emits correct sequence', () {
    expectLater(numberStream(), emitsInOrder([1, 2, 3]));
  });
}

emitsInOrder() verifies that all expected values are emitted sequentially.

4. Mocking Async Dependencies with mockito

For unit tests, mocking external dependencies (e.g., API services, databases) is essential to isolate the function being tested. The mockito package helps create mock implementations.

Example: Mocking an API Call with mockito

import 'package:mockito/mockito.dart';
import 'package:test/test.dart';

// Mock class
class MockService extends Mock {
  Future<String> fetchUser() async => 'Mock User';
}

void main() {
  test('fetchUser returns mocked data', () async {
    final service = MockService();

    when(service.fetchUser()).thenAnswer((_) async => 'Mock User');

    expect(await service.fetchUser(), equals('Mock User'));
  });
}

Instead of making real API calls, the test uses mocked responses, making tests faster and more reliable.

5. Handling Errors in Async Tests

To verify error handling in async functions, use throwsA().

Example: Testing Error Handling in Async Code

Future<String> fetchDataWithError() async {
  await Future.delayed(Duration(seconds: 1));
  throw Exception('Network error!');
}

void main() {
  test('fetchDataWithError throws an exception', () async {
    expect(fetchDataWithError(), throwsA(isA<Exception>()));
  });
}

The test checks that calling fetchDataWithError() results in an exception.

Conclusion

Asynchronous programming is essential for building efficient, responsive, and scalable applications in Dart. The async package enhances Dart’s built-in async capabilities by providing powerful tools to manage Futures, Streams, and concurrent tasks with ease.

By leveraging utilities like FutureGroup for parallel execution, StreamQueue for sequential stream processing, and CancelableOperation for task management, developers can write cleaner, more predictable async code. Additionally, proper testing of async functions ensures reliability and robustness in real-world applications.

Mastering asynchronous programming in Dart, along with the async package, will help you build high-performance applications that handle real-time data, background tasks, and API calls seamlessly.

About the author
Ini Arthur

Dart Code Labs

Dart Code Labs - explore content on Dart backend development, authentication, microservices, and server frameworks with expert guides and insights!

Dart Code Labs

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Dart Code Labs.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.