Asynchronous programming is a paradigm where code continues executing without waiting for long-running operations to complete, handling their results when they become available.
In this article, I will outline in my opinion the most relevant APIs for working with asynchronous code, as well as explain a little of the inner workings of the Dart VM in terms of concurrency.
Futures
By far the most common async concept you will encounter are Futures. When interacting with servers or the native platform, every API you'll use will return a Future.
Async/Await
Future<String> myAsyncFunction() async {
final file = File("filename");
final contents = await file.readAsString();
return contents;
}
There are three main keywords here - Future, async and await. The most important one in my opinion, is await
. It allows you to wait for the method to finish and only then continue with the execution. And if you want to use the await
keyword, you must denote the function as async
and if you want to do that, it must have a return type of Future
.
The part in the parentheses behind the Future
keyword is called a generic type parameter. This means that the compiler will expect the returned value to be of the type you specify here. In our case, when you call the myAsyncFunction
, the returned value will be a String. And if you avoid specifying the generic type, the resulting Future will contain a dynamic
value.
You may ask - "But we were just talking about not having to wait for long-running operations to complete, so why are we doing this?" That point still stands for the definition of asynchronicity. But here we want to wait for the result before continuing the execution, as further logic depends on the contents of the file.
Callbacks
But if we do indeed want asynchronicity, there is a second way of using Futures, which is via callbacks.
void mySyncFunction() {
myAsyncFunction().then((result) {
print(result);
});
}
In this example, myAsyncFunction
is called and when it returns the result, it will be printed out. For those of you who've programmed in JavaScript, this will be instantly recognizable.
The Future
class provides several methods you can use. The one I use the most after .then, is the .timeout method, as sometimes you must stop waiting for the future to complete. If you provide the onTimeout
callback, the value you return from it will be returned from the timed out future.
There are also some constructors and static methods I use quite frequently. Future.delayed is a constructor that takes in a Duration and a callback, and you use that to delay the execution of that callback for the given duration. Future.value is another constructor if you want to immediately return the given value for some reason, like perhaps in some type requirement situations.
Of the static methods I mostly use the .wait method, as it allows you to run multiple futures in parallel. It completes once every future has completed, or when one of them throws an error. If you set eagerError
parameter to true, the returned future will complete with the error from the first failing future immediately, while if it's left on false, all other futures will run as well, but will silently drop their errors.
Errors
The futures, as non-futures alike, can cause errors. Among the methods I've linked to above, there are several options to handle errors, like the catchError
method, onError
method, and the onError
parameter inside then
. I really don't like using any of them, to be honest. We have the async/await keywords for a reason, which is to avoid callback hell by linearizing the execution and thus making the code more readable.
In order to handle errors like an adult, I suggest you use the try/catch
blocks. One important note - you MUST use the await keyword when doing so. If you don't, the catch
block will not be executed and the exception will not be handled.
After errors, we're left with only a few methods, including asStream
, ignore
and whenComplete
, none of which I ever use, so I'll skip them for now.
Completers
These bad boys have been very useful to me, mostly because sometimes there are futures you want to await, but in a different scope. Consider this example.
- You've got a login screen, where you call
loginUser
when the user taps the login button. - This launches the login flow and whenever the login function finishes successfully, you know the user is logged in.
- But say your user has a profile you want to fetch before continuing to the next screen and you're using Firebase Auth's
authStateChanges
Stream to centralize the handling of user's authentication state. - Now you need to wait for the stream to emit a signed in event so you can call the getProfile function.
In order for you to wait for that getProfile function, a future which you don't have access to in the current scope, you can use a Completer
.
final signedInCompleter = Completer<UserProfile>();
void listenToAuthChanges() {
FirebaseAuth.instance.authStateChanges().listen((user) {
if (user != null) {
handleUserSignedIn(user);
}
});
}
Future<void> handleUserSignedIn(User user) async {
try {
final res = FirebaseFirestore.instance.doc("users/${user.uid}").get();
final profile = UserProfile.fromJson(res.data());
if (!signedInCompleter.isCompleted) {
signedInCompleter.complete(profile);
}
} on FirebaseException catch (e) {
print(e);
}
}
void someOtherFunction() {
await signedInCompleter.future;
}
Now, you should also be careful here, because if you call .complete
on the completer once it has already completed, it will throw an error. Make sure to call the .isCompleted
getter on the completer first.
Once you've got your completer you can then wait for the future to complete by awaiting the .future
property of the completer.
Timer
An honorable, and necessary mention is the Timer object. This is a great utility for running some code with a delay, just like Future.delayed, but more importantly for running some code periodically. You can also get the current tick of the timer and check if it isActive . And since we're already talking about time, I might as well mention the Stopwatch class for measuring elapsed time, like when measuring the performance of specific async calls.
FutureBuilder
Everything we've talked about so far was purely Dart, but now we're in Flutter territory. FutureBuilder is the widget that gives us some quite useful APIs to handle different states of our Future.
Future<String> _data;
Future<String> getData() { ... }
@override
void initState() {
_data = getData(); // note the absence of the await keyword
super.initState();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _fetchData,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator(); // Show loading spinner
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}'); // Show error message
} else {
return Text('Data: ${snapshot.data}'); // Show fetched data
}
},
);
}
It's really simple - you give it a Future, and a builder callback which includes the AsyncSnapshot object. This object has a few properties we can use to render different UI based on the state of the Future - .hasData
and .hasError
or we can just check that .data
or .error
are null or not.
When the future is still loading, we can show a loading indicator, when we've got data we render our data state and if there's an error we render the error state. Of course we can also go deeper into each branch to render different UI based on different data and errors.
Note that I've created the future outside of the FutureBuilder widget. If you create the future inside the FutureBuilder, as so: future: fetchDataFuture()
, then the future will be called on every rebuild of the FutureBuilder's parent widget. And because theoretically that can happen at most once every frame, we certainly don't want to call it 60 times per second. The UI will be unusable and we risk sending a ton of requests to our resource.
Streams
Another piece of the asynchronicity puzzle are Streams. These objects allow to continuously send data back and forth within your app and between your app and the outside world, via WebSockets or EventChannels for example. I have actually already mentioned streams above, when talking about the authStateChanges
stream from the FirebaseAuth package.
There are two main types of streams - single-subscription and broadcast streams.
Single-subscription streams
Single-subscription streams are the default and are called that way because when you call the .listen method on a Stream, it returns a StreamSubscription object. This subscription object lets you control the stream using the pause, resume and cancel methods.
The single part in the name means you can only subscribe to the stream once. Single-subscription streams start emitting events only once they are being listened to, which means you can be sure to get all of the emitted events when you listen to this stream. If you try to listen to the same stream again, you will get an exception.
[!NOTE]
I haven't had the need to create single-subscription streams yet, but I do see how they can be useful if you need to process some large amount of data in chunks once and be done with it, like in file I/O and network requests. But usually that kind of thing is abstracted away for you by the SDK and other libraries.
The first way of creating single-subscription streams is by using the async*
and yield
keywords.
void main() {
final stream = buildStream();
stream.listen((e) {
print(e);
});
}
Stream<DateTime> buildStream() async* {
for (int i = 0; i < 10; i++) {
await Future.delayed(Duration(seconds: 1));
yield DateTime.now();
}
}
The second way is using the StreamController. You initialize the controller, call the .add method to add events to it and listen to the emitted events using the .stream property.
void main() {
final controller = StreamController();
for (int i = 0; i < 10; i++) {
controller.add(i);
}
controller.stream.listen((e) {
print(e);
});
}
Remember that by default, the controller creates a single-subscription stream, so we will not miss any events here just because we've started listening to it after adding the events.
In this example I've used the .listen
method to listen to the events, but you can also use the await for
syntax.
void main() async {
final controller = StreamController();
for (int i = 0; i < 3; i++) {
final delay = Duration(milliseconds: 200 * i);
Future.delayed(delay, () => controller.add(DateTime.now()));
}
await for (var e in controller.stream) {
print(e);
}
}
The await for
syntax works the same way for Streams as the await
keyword for does for Futures, so when an error is thrown inside the stream, a try/catch
statement outside will catch it and the stream will stop producing events.
void main() async {
final controller = StreamController();
for (int i = 0; i < 5; i++) {
final delay = Duration(milliseconds: 200 * i);
Future.delayed(delay, () {
if (i == 2) {
controller.addError("Whoops!");
}
controller.add(DateTime.now());
});
}
try {
await for (var e in controller.stream) {
print(e);
}
} catch (e) {
print(e);
}
}
// prints out
// 2025-03-06 14:35:15.635
// 2025-03-06 14:35:15.837
// Whoops!
// .. no more events
If you don't want to exit the stream on error, you can use the handleError wrapper method.
await for (var e in controller.stream.handleError((e) => print(e))) {
// ...
}
If you're using the asynchronous .listen
approach, set the cancelOnError
parameter to false.
controller.stream.listen((d) {
print(d);
},
cancelOnError: false
);
Broadcast streams
These are in my opinion the more useful kind of stream, because you can listen to it from multiple places.
You can create broadcast streams in two ways:
void main() {
// way number 1
final _controller = StreamController.broadcast();
// way number 2
final stream = buildStream().asBroadcastStream();
}
The first way is by using the .broadcast
StreamController constructor and the second is by using the .asBroadcastStream
method on the stream itself.
Transformations
You can transform streams in multiple ways. The most common one will probably be via the methods available on the Stream object, many of which are the same as those on the Iterable class, like .map
, .cast
, .where
, .expand
, and more.
You can also encapsulate transformations by extending a StreamTransformer like this:
class LoggingTransformer extends StreamTransformer<int, int> {
@override
Stream<int> bind(Stream<int> stream) {
return stream.map((data) {
print('Processing: $data');
return data;
});
}
}
// Usage
stream.transform(LoggingTransformer());
StreamBuilder
Similarly to FutureBuilders, StreamBuilder is a specific Flutter widget that supports building different UI based on a given stream.
Stream<String> _stream;
Stream<String> createStream() { ... }
@override
void initState() {
_stream = createStream(); // note the absence of the await keyword
super.initState();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<String>(
stream: _stream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator(); // Show loading spinner
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}'); // Show error message
} else {
return Text('Data: ${snapshot.data}'); // Show fetched data
}
},
);
}
Conclusion
Async programming is something you will constantly use when developing Flutter apps, so make sure to study these concepts and try them out on your own so you know what to use and when!
There are a lot of things I haven't covered here due to time constraints and the volume of information, so make sure to check out the sources if you want to get a deeper picture on async programming for Dart & Flutter.
This article is also more focused on the Flutter side of this concept, so I suggest you to invest more time into Streams, Zones and Isolates if you are developing server-side apps with Dart.
Top comments (0)