dart-futures-image

Why Await? Futures in Dart & Flutter

Futures in Dart might seem straightforward at first glance, but dig a little deeper, and you’ll find they can be more intricate than you initially thought. Ever wondered how these asynchronous operations work with Dart’s single-threaded event loop? Or perhaps you’ve stumbled upon some of the common pitfalls that took you unnecessary hours to debug? Fear not, because we’re about to go on a journey to unravel the mysteries of Futures in Dart and Flutter. We’ll take a look in the inner workings, see how they interact with the event loop, and highlight some of the issues you might encounter along the way.

Get ready to have a clearer vision of your future… just kidding… of Futures in Dart/Flutter.


What is a Future 🔮

Dart runs most of its code on a single-threaded event loop. Think of it as the main highway for your app’s tasks – rendering UI, handling user taps, and running your Dart logic. This single-lane model works great for a lot, but here’s the catch: if any piece of code takes too long to execute on this main thread, it’ll block everything. So, why not just throw every asynchronous call onto a separate thread?

Disclaimer: If you already feel super familiar in general how futures work, don’t hesitate to jump ahead to the section where we explain how they work under the hood.

Why no threads 🧵?

While many languages rely on multi-threading for concurrency, Dart often prefers its efficient single-threaded event loop combined with asynchronous patterns like Futures for handling I/O-bound operations. Although Dart does support true parallelism for CPU-intensive tasks using Isolates, the event loop model often provides lower overhead and complexity for managing lots of concurrent I/O tasks – a common pattern in event-driven architectures. If you’re curious about why the single event loop pattern became so popular, there are plenty of articles out there that dive into the details.

Getting to know your Future(s)

At its core, a Future<T>in Dart is an object representing the eventual result of an asynchronous operation. Think of it not as the value itself, but as a promise or a placeholder for a result (or an error) that will become available at some point later.

The <T> part is a generic, specifying the type of value you expect the Future to produce upon successful completion. So, a Future<String> promises eventually to yield a String, and Future<void> signifies that the asynchronous operation completes but doesn’t produce a specific value. You can still wait for it to complete, if one of your following tasks relies on it to be finished.

A Future can have two states:

  1. Uncompleted: When you first receive a Future from an asynchronous call, it’s typically in this state. The operation has started, but the result (or error) is not yet available.
  2. Completed: Eventually, the operation finishes, and the Future transitions to a completed state. This completion can occur in two distinct ways:
    • Completed with a value: The operation finished successfully, and the Future now holds the resulting value of type <T>.
    • Completed with an error: The operation failed for some reason (e.g., network error, file not found, exception thrown), and the Future now holds an error object (often some kind of Exception) and potentially a Stacktrace.

But how does this work under the hood with Dart’s event loop?

When an asynchronous operation (like network I/O handled by the system) finishes its work, it doesn’t immediately interrupt your currently running Dart code. Instead, it signals the Dart runtime, which then typically adds a task to the event queue. When this task is processed, Dart uses the microtask queue to schedule the actual completion of the Future (like running .then() callbacks or resuming code after an await).

The event loop is an invisible process that manages the execution of code events and callbacks. It’s a crucial part of the Dart runtime ensuring that all the calls in your Dart code run in the correct order. The Dart event loop works by continuously checking two queues, the event queue and the microtask queue.

Event queue

The event loop’s job is to handle external events like user interactions (mouse clicks, taps), I/O operations (network requests, file access), timers, and messages between Dart isolates. These events are added to the event queue

Microtask queue

The micro task queue is for short, asynchronous internal actions that originate from your Dart code. Microtasks have a higher priority than events and are processed before the event loop looks at the event queue. Think of them for tasks that need to happen right after the current synchronous code finishes but before the next event is processed.

Zones

For more advanced scenarios, especially around error handling in asynchronous code, Dart provides the concept of Zones.Zones allow you to create isolated execution contexts and handle errors that might occur within them without crashing the entire application.

When to use a Future and when a Thread 🔮 vs.🧵

Most of the time you automatically know when to use a Future. E.g. the http package will only return Futures, so you are forced to use them. But when is actually the right time to start an Isolate?

The fundamental rule is: any operation that has the potential to take a non-trivial amount of time should be performed asynchronously. While “non-trivial” can be subjective, in the context of a smooth user interface aiming for 60, (or even 120) frames per second, you only have about 16 (8) milliseconds per frame for all work (building widgets, layout, painting, running Dart code). Any single operation taking longer than a few milliseconds on the main isolate can contribute to dropped frames (jank) or, in worse cases, a completely frozen UI.

When to use an Isolate

For CPU-bound tasks – operations primarily limited by processing speed rather than waiting for external resources – the correct approach is to use Isolates. Isolates are independent Dart execution contexts running on separate threads, each with its own memory heap and event loop. This allows true parallelism, ensuring heavy computations don’t block the main UI isolate.

Pitfalls and Misconceptions 🪤

There are more misunderstandings and potential pitfalls around Futures than you might think. Let’s take a look at some of the most crucial ones to keep you safe in the future.

await vs then

  • await: Suspends execution at the await line, letting other tasks run. Once the Future completes, execution resumes. It offers clean, readable, and sequential async code.

  • .then(): Registers a callback that runs when the Future completes, without pausing the current function. Useful outside async functions, but nesting and error handling can get messy.

Recommendation: Use await for clearer, more maintainable async code. Use .then() only when necessary.

await is scoped

The awaitkeyword is scoped to the async function it’s used in, meaning it only pauses execution within that specific function—not the entire program. When await is called, control is returned to the event loop, allowing other tasks to run while the Future completes. This scoped suspension keeps the rest of your app responsive and enables writing asynchronous code that looks and behaves like synchronous logic within that function.

Consider this example:

void main() {
  print('1. Start main');

  doAsyncTask();

  print('4. End main');
}

Future<void> doAsyncTask() async {
  print('2. Start async task');

  await Future.delayed(Duration(seconds: 2));
  print('3. End async task');
}

The output clearly shows that main continues to execute even while doAsyncTask is paused waiting for the Future.delayed() to complete:

1. Start main 
2. Start async task 
4. End main 
3. End async task

The alternative: FutureOr?

FutureOr<T> in Dart is a union type, meaning a function can return either a direct value of type T or a Future<T>. This provides flexibility for APIs, making it easier to override methods that might return results synchronously or asynchronously. The await keyword handles both cases smoothly.

A Future that never ends

A Future that never ends is an asynchronous operation that might never complete. While most asynchronous tasks are expected to eventually finish, there’s no guarantee within the Future contract itself that they will. This can happen if an operation gets stuck due to bugs, deadlocks, or waiting on a resource that never becomes available (like an unresponsive server without a timeout).

If you awaita Future that never resolves, your async function will stay paused at that point forever. Similarly, if you use .then(), the callback will never be called. This can cause parts of your app to become unresponsive and lead to resource leaks if cleanup code (like in whenComplete() or a finally block) never gets to run.

Most of the Futures you encounter already have a build-in timeout and will return an error at some point. For other cases to avoid this, you can use Future.timeout(). This method takes a Future and a Duration. If the original Future doesn’t complete within the given time, timeout() will throw a TimeoutException (or trigger an onTimout callback). This way, you can handle situations where an operation might hang and keep your app responsive. For more details, check out the Future.timeout() documentation.

Catching Errors: Handling Future Failures 🫴

Proper error handling is crucial in asynchronous programming. Unhandled errors from Future can cause your application to crash or silently fail, making it essential to catch and manage them effectively.

Awaited Futures (try-catch)

When using await, error handling is straightforward with a try-catchblock. If the awaited Future completes with an error, it will throw that error, which can be caught by the surrounding catch block.

Future<void> handleAwaitError() async {
  try {
    var data = await potentiallyFailingOperation();
    print("Success: $data");
  } catch (e, s) { // Catch the error
    print("Caught error during await: $e");
    print("Stack trace: $s");
    // Handle the error...
  }
}

Future.then() Futures (.catchError)

When using .then(), you handle errors with the catchError() method. This method registers a callback to catch errors that occur either from the original Future or from exceptions thrown inside any preceding .then() callback in the chain.

void handleThenError() {
  potentiallyFailingOperation()
   .then((data) {
      print("Success: $data");
      // if (somethingIsWrong) throw Exception("Problem in.then");
    })
   .catchError((error, stackTrace) { // Catch errors from Future or.then()
      print("Caught error in chain: $error");
      // Handle the error...
    });
}

The Critical Pitfall: Unawaited Futures

A major source of bugs is when you call a function that returns a Future, but you neitherawait its result nor attach a catchError() handler. What happens if the Future completes with an error?

The error becomes an uncaught asynchronous error. Depending on the context (e.g., in debug vs. release mode, or a specific Zone configuration), this can either crash your application (common in release builds) or silently log the error to the console, hiding critical failures in your app logic.

Why does this happen? The error surfaces in the event loop, but there is no registered handler to catch it for that specific Future instance.

Rule: Always ensure that a Future’s potential error is handled. You can do this by:

  • await the Future inside a try-catch,

  • Using .catchError() to attach an error handler, or

  • Ensuring errors are managed internally within the async function if you intentionally use a “fire-and-forget” pattern (though this should be used cautiously).

By proactively handling errors, you prevent unforeseen crashes and ensure your app behaves predictably, even in the face of asynchronous failures.

Some neat Lints for your future

The Dart analyzer includes helpful lint rules. Ensure you have these enabled in your analysis_options.yamlfile:

  • unawaited_futures: Warns when you call a function returning a Future and don’t do anything with the result (no await, .then(), .catchError(), or assignment to a variable). This is the primary rule for catching potentially unhandled Futures.
  • discarded_futures: A related rule, specifically warning when a Future is obtained in a context where its value cannot be used (like a void expression), often highlighting fire-and-forget calls that might need attention.

Important Caveat for unawaited(): Using unawaited() does not magically handle errors. It merely suppresses the lint. If the Future you pass to unawaited() completes with an error, that error will still be an uncaught asynchronous error unless it’s handled inside the async function or you are consciously accepting the risk for specific, non-critical operations. Use unawaited() with caution and only when you are certain that not handling the result or potential error is acceptable or handled elsewhere.

Conclusion: Embrace the Future!

Hopefully, this deep dive has shed some light on the intricacies of Futures in Dart and Flutter. Understanding how they work with the event loop, the nuances of async/await, and the importance of error handling will undoubtedly make you a more effective Flutter developer. So go forth and embrace the Future!

Another topic that seems more obvious at first glance, but can be more complicated in detail, is class modifiers in Dart. You can learn more about them in this article

Thanks for reading!

Share via
Copy link
Powered by Social Snap

Get notified when our next article is born!

(no spam, just one app-development-related article
per month)