No history yet

Advanced Asynchronous Patterns

Inside the Event Loop

You're likely comfortable using async and await to handle operations like network requests. But what's happening under the hood? Dart manages asynchronous tasks using a single-threaded model powered by an event loop and two distinct queues: the event queue and the microtask queue.

The event queue handles external events like I/O, timers, and drawing events. The microtask queue is for short, internal actions that need to run before handing control back to the event loop. The key rule is that the microtask queue is always emptied before the next item from the event queue is processed. This priority is crucial for scheduling tasks that must execute immediately after a piece of code finishes, without interruption from external events.

For example, a Future.then() callback is scheduled as a microtask, whereas a Future.delayed() schedules a task on the event queue. Understanding this distinction is key to predicting and controlling the exact order of your asynchronous operations.

Handling Continuous Events

While a Future represents a single value that will arrive later, a Stream represents a sequence of asynchronous events. Think of it as an async Iterable: a list of values delivered over time. You can create and manage your own streams using a StreamController.

// Create a controller
final controller = StreamController<int>();

// Listen to the stream
final subscription = controller.stream.listen((data) {
  print('Received: $data');
});

// Add data to the stream
controller.add(1);
controller.add(2);

// Don't forget to close it to release resources!
controller.close();

By default, streams are single-subscription. Only one listener is allowed. If you need multiple parts of your app to react to the same stream of events, you must use a broadcast stream. You can create one by calling the StreamController.broadcast() constructor. Listeners can subscribe and unsubscribe at any time, but they won't receive events that were sent before they subscribed.

A common mistake is forgetting to cancel a stream subscription. In stateful widgets in Flutter, a stream that isn't cancelled in the dispose() method can lead to memory leaks and errors when it tries to update a widget that's no longer in the tree.

Generating Sequences

Dart provides special syntax for creating sequences lazily: generator functions. They produce values on demand, which is highly efficient for large or infinite sequences.

A synchronous generator (sync*) returns an Iterable. It uses the yield keyword to emit each value. The function's execution pauses at each yield and resumes only when the next value is requested.

// Generates numbers from start to end, one by one.
Iterable<int> count(int start, int end) sync* {
  for (int i = start; i <= end; i++) {
    // Pauses here and sends 'i' out.
    yield i;
  }
}

void main() {
  // Nothing is computed until the loop starts.
  for (final number in count(1, 5)) {
    print(number);
  }
}

An asynchronous generator (async*) works similarly but returns a Stream. It's perfect for when producing each value is an asynchronous operation itself. You use yield to add values to the stream and can use await inside the function.

// Fetches paginated data and yields it as a stream.
Stream<String> fetchReportPages() async* {
  for (int i = 1; i <= 3; i++) {
    // Simulate a network delay for each page.
    await Future.delayed(Duration(seconds: 1));
    yield 'Report Page $i';
  }
}

void main() async {
  await for (final page in fetchReportPages()) {
    print(page);
  }
}

True Parallelism with Isolates

Even with async-await, Dart is single-threaded. If you perform a very heavy synchronous calculation, like parsing a massive JSON file or processing an image, your app's UI will freeze. This is because the single thread is too busy to handle UI events. This is often called UI jank and leads to a poor user experience.

Lesson image

The solution is to move this heavy work off the main thread using . An isolate is an independent worker with its own memory and event loop, running on a separate CPU core if available. This allows your main isolate to remain responsive to user input while the other isolate crunches numbers. Communication between isolates happens by passing messages, ensuring no shared state and preventing race conditions.

While you can manage isolates manually, the Flutter framework provides a simple, high-level function called compute(). It spawns an isolate, runs your function on it with the given input, and returns a Future with the result. This is the recommended way to handle most background processing needs in a Flutter app.

import 'package:flutter/foundation.dart';
import 'dart:convert';

// A top-level or static function to be run in an isolate.
Map<String, dynamic> _parseJson(String encodedJson) {
  return jsonDecode(encodedJson);
}

Future<Map<String, dynamic>> parseJsonInIsolate(String json) {
  // compute() runs _parseJson in a background isolate.
  return compute(_parseJson, json);
}

// In your widget...
void processLargeData() async {
  setState(() { /* show loading indicator */ });
  final largeJsonString = await fetchLargeJson();
  // The UI won't freeze while this is running.
  final data = await parseJsonInIsolate(largeJsonString);
  setState(() { /* hide loading, show data */ });
}
Quiz Questions 1/5

In Dart's event loop model, which queue is always processed to completion before the event loop handles the next item from the other queue?

Quiz Questions 2/5

Your Flutter app's UI becomes unresponsive while processing a large image. What is the primary cause of this 'UI jank' and what is the recommended high-level solution in Flutter?

With these advanced patterns, you can build applications that are not only correct but also efficient and responsive, providing a fluid user experience even under heavy load.