The Hidden Pitfalls of async/await in Flutter
Flutter code runs on a single-thread model, similarly to Node and JavaScript.. This make things simple and efficient because the code does not constantly switch between threads, and that can be slow. Flutter handles few other threads like rendering and GPU communication internally, but for most of us app developers, that’s urrelevant. Normally we only need to care for the app code flow, which is single-threaded.
Moderns apps usually have to juggle a number of tasks at once, even when the code is single-threaded. For instance a network call. The app is asking a server for a data, and while waiting for the response, it shows a waiting cursor or some popup with active “cancel” button. This is where async and await comes in.
Async functions and methods allow you to execute parts of the code in quasi-parallel mode. Internally it’s a hidden event processing loop. The loops works as a queue. When an async method or function encounters await call, the event loop moves to the next section of code while the async method waits in the background. Once the awaited call is completed, the event loop moves back to a previous place.
Here’s a quick and basic example of the async method calling the network:
Future<String> callServer() async {final ret = await http.get('https://google.com/');if (ret.statusCode == 200) {return ret.body;} else {// handle the error}}
When the code flow encounters await, Flutter event loop freezes callServer()
execution until http.get call comes back.
What’s also good about await and async: the code is more readable and simpler, because you don’t need to provide any callbacks or pointers to return functions. It’s also easy to control error handling with try - catch blocks. However, it’s also possible to create a code that dies unexpectedly on await call, when the calling method never returns.
This part of the article is designated for devs who already know what BLoC is and how to use it. Here’s a link to a BLoC vs Cubit article that you may find interesting.
When it comes to handling async tasks inside BLoC, it’s a perfect match. You trigger an event, fetch data with async and await, and let BLoC handle the rest. This keeps your UI responsive while waiting for the results, no matter if it’s a network request or some database query. You write simple, readable code that plays nicely with the state management.
Usually in the real-life scenario an event processing method within BLoC is also an async method. It may call some async services, like network https calls, or native Android or iOS calls using method channel.
Need to fetch data from the web? Just fire an event, await the response, and emit a new state once the data rolls in. No hanging, no freezing, just smooth operation. Here’s a quick example:
class FetchDataEvent extends BlocEvent {}class MyDataBloc extends Bloc<BlocEvent, DataState> {MyDataBloc() : super(InitialState()) {on<FetchDataEvent>(_onFetchData);}Future<void> _onFetchData(FetchDataEvent event, Emitter<DataState> emit) async {emit(LoadingState());try {final data = await fetchDataFromWeb();emit(LoadedState(data));} catch (error) {emit(ErrorState());}}}
With this setup, your app stays neat, your logic stays organized, and your async code runs like a breeze.
Imagine a BLoC where events are processed one by one. Whether you enforce this behavior with a sequential transformer or you are stick with the old BLoC package. There will be a FIFO queue for events - first in, first out. Now, when an event hits an asynchronous method and waits, the entire BLoC stops. That’s not a bug - it’s a feature. This makes state changes consistent and predictable.
But if that asynchronous call never returns (because of some unstable service), then your BLoC is effectively frozen and can’t process any new events.
I learned this the hard way when refactoring a legacy app where BLoCs were singletons and events were processed sequentially. On rare occasions, a network error would occur and suddenly the entire app would stop responding - especially on screens based on that frozen BLoC. That’s the kind of headache no one wants.
Here’s how to avoid freezing:
try {final ret = await someAsyncMethod().timeout(const Duration(seconds: 10),onTimeout: () {throw TimeoutException('The call took too long!');},);} catch (e) {if (e is TimeoutException) {// Handle the timeout exceptionloggy.debug('timeout in someAsyncMethod() ${e.message ?? ''}');}
handle network issues upfront: Implement timeouts and retries in the network services layer.
avoid using singletons: If something breaks and the BLoC freezes, leaving a broken screen won’t help. Using singletons just spreads the problem.
With these fixes, you can keep the BLoC running without the risk of freezing when something goes wrong.
I’ve uploaded to my GitHub an example app that is intentionally showing app the problem. The app is a simple project demonstrating timeout issues within a BLoC.
Using asynchrony wisely is a key to keeping your Flutter apps fast, responsive, and fluid. Don’t let sloppy asynchronous code block your BLoC and ruin your user experience. Handle timeouts, manage network errors, and structure your code cleanly - a little care up front will save you from big problems later. Keep your app moving and let your code do the hard work effortlessly.
Quick Links
Legal Stuff