The Hidden Cost of Async Misuse in Flutter (And How to Fix It)
I still remember the week I hunted a mysterious Flutter performance issue which had no crashes or error logs; just a sluggish app and somehow an increase usage of memory. Guess what? The issue was a missing await
here, an unguarded setState()
there.
These bugs don’t crash your app or shout errors; they silently cause memory leaks, UI glitches, and race conditions that surface only after extended use. Problem is that when you spot these behaviors or degraded performance, the damage is done.
In this post, I’ll share what I learned about async misuse’s hidden costs in Flutter, with real examples and fixes. We’ll explore common pitfalls (some of which I’ve embarrassingly written myself), see their sneaky production impacts, and how to use tools like DCM to catch them early.
How Async Gets Misused
Let’s talk about a few ways async commonly gets misused in Flutter:
- Unawaited
Futures
in lifecycle methods - Redundant
async/await
- Inefficiently awaiting independent Futures
- Misuse of
FutureOr<T>
- Overusing
.then()
chains
Let's go deeper into each of them:
Unawaited Futures
in Lifecycle Methods
It’s easy to fire off a Future
and forget to await
it. In Flutter, this often happens in non-async methods like widget constructors or initState()
, where you can’t use await directly. The intent might be to "fire-and-forget" a task, but an unawaited Future
can lead to unexpected behavior. Here is a problematic example:
class _ProfilePageState extends State<ProfilePage> {
void initState() {
super.initState();
fetchUserProfile(); // ⚠️ This Future is launched and never awaited
}
Future<void> fetchUserProfile() async {
final user = await api.getUser();
setState(() {
profile = user;
});
}
}
In the above, fetchUserProfile()
is an async method that we call inside initState
without awaiting it. This means initState
will finish execution immediately, and fetchUserProfile
will continue in the background.
What’s the risk? If the user navigates away before api.getUser()
completes, the callback inside setState
will try to run on a disposed widget which is a classic source of memory leaks and "setState after dispose" errors. Also, any error in api.getUser()
would be unhandled, potentially crashing the app but not in a way that’s obvious during development.
class _ProfilePageState extends State<ProfilePage> {
void initState() {
super.initState();
// Properly handle the Future
_loadProfile();
}
Future<void> _loadProfile() async {
try {
final user = await api.getUser();
if (!mounted) return; // ✅ Avoid updating if widget is gone
setState(() {
profile = user;
});
} catch (e) {
// Handle error (show a message, etc.)
}
}
}
Here, we delegate to an async helper method _loadProfile()
which awaits the result and catches errors. We also check mounted
before calling setState
to ensure the widget is still in the tree, a best practice recommended in Flutter’s docs. If truly a "fire-and-forget" call is needed, for example, no state update, just triggering something, we can mark it explicitly as unawaited
. Marking as unawaited()
signals to other devs and static analyzers that we intentionally do not await this future
DCM Rule
The use-setstate-synchronously
rule catches these risky scenarios. The recommended fixes are either checking if the widget is still mounted or restructuring state management altogether, such as using FutureBuilder
:
One way to fix the issue above is to use mounted:
onPressed: () async {
final data = await fetchData();
if (mounted) { // ✅ safely updating state
setState(() {
message = data;
});
}
},
Or alternatively you can use FutureBuilder
:
class _MyWidgetState extends State<MyWidget> {
Future<String>? messageFuture;
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
setState(() {
messageFuture = fetchData();
});
},
child: FutureBuilder<String>(
future: messageFuture,
builder: (context, snapshot) {
return Text(snapshot.data ?? 'Loading...');
},
),
);
}
}
DCM avoid-async-call-in-sync-function
rule will flag cases where an async function is invoked from a sync context without awaiting. In our bad example, it would warn like:
Avoid invoking async functions in non-async blocks.
Try awaiting it, wrapping in 'unawaited(...)' or calling '.ignore()'.
DCM even suggests using Dart’s unawaited()
from dart:async
for fire-and-forget calls.
Here is a good example:
Future<void> asyncValue() async => 'value';
class SomeClass {
SomeClass() {
unawaited(asyncValue()); // ✅ Correct, unawaited
}
Future<void> asyncMethod() async => asyncValue(); // ✅ Correct, async method
}
Not only the rules, but these issues related to performance or memory-leaks and more may end up in the DCM Dashboards and potentially without you even have enabled any rules, DCM can help you have an overview of these issues and address them as soon as they appear not in one projects but within all of your projects and entire organization.
If you want to try DCM Dashboards, contact us.
Redundant async/await
Not every function that returns a Future
needs to be async. If you’re not awaiting anything inside, the async keyword (and the Future
it implicitly creates) adds needless overhead. Similarly, an await that immediately precedes a return of a Future
value might be doing extra work for no reason. These redundancies can slow down your code and muddy its intent.
Let's look at an example:
// ❌ Unnecessary async; no await inside
Future<String> readUsername() async {
return storage.read('username'); // returns Future<String>
}
// ❌ Redundant await in a return
Future<int> getUserId() async {
int id = await fetchUserIdFromCache();
return id;
}
In readUsername()
, we marked the function async but simply return another Future
. We could have returned that Future
directly without async. The second function getUserId()
uses await and then immediately returns the value; this could be shortened by returning the Future
itself. Though note that if there was a try/catch
around it, using await might be necessary to catch errors properly; here it’s just redundant.
Let's fix it.
Future<String> readUsername() {
return storage.read('username'); // ✅ No async keyword needed
}
Future<int> getUserId() {
return fetchUserIdFromCache();
// ✅ Or simply: Future<int> getUserId() => fetchUserIdFromCache();
}
By removing unnecessary async/await, we make the code more efficient and straightforward. The functions will still return a Future
as before, but we avoid scheduling extra microtasks that serve no purpose. Fewer microtasks reduce event-loop delays, improving frame rendering performance.
DCM Rule
The avoid-redundant-async
rule catches this scenario. It looks for functions or methods where the async keyword is used without a clear need (no await, no asynchronous error/return that requires it). The Dart analyzer has a similar guideline: “DON’T use async when it has no useful effect.” If removing async doesn’t change the function’s behavior, then it’s extraneous. DCM will hint to rewrite such functions in sync form, possibly even providing an auto-fix.
Inefficiently Awaiting Independent Futures
Sometimes, your app needs to fetch multiple pieces of data or perform several asynchronous operations before it can proceed, for example, when loading a complex screen. If these operations don't depend on each other's results, awaiting them sequentially is a hidden performance bottleneck. Each await pauses execution until that specific Future
completes, even if other independent tasks could have been running in the meantime.
Let's look at an example:
Future<UserProfile> fetchUserProfile() => Future.delayed(Duration(seconds: 2), () => UserProfile());
Future<Activity> fetchRecentActivity() => Future.delayed(Duration(milliseconds: 1500), () => Activity());
Future<Preferences> fetchAppPreferences() => Future.delayed(Duration(seconds: 1), () => Preferences());
// ❌ Awaiting independent futures sequentially
Future<void> loadDashboardData() async {
final userProfile = await fetchUserProfile(); // Simulates a 2-second task
final recentActivity = await fetchRecentActivity(); // Simulates a 1.5-second task (starts after userProfile)
final appPreferences = await fetchAppPreferences(); // Simulates a 1-second task (starts after recentActivity)
// Total time roughly: 2s + 1.5s + 1s = 4.5 seconds
// DO SOMETHING
}
Here is the print output if you try
Starting sequential dashboard data load...
User profile loaded at 2025-05-25 00:21:18.703
UserProfile instance
Recent activity loaded at 2025-05-25 00:21:20.207
Activity instance
App preferences loaded at 2025-05-25 00:21:21.210
Preferences instance
Sequential dashboard load took: 4521ms
Sequential Dashboard UI updated.
In loadDashboardData()
, WorkspaceRecentActivity()
only starts after WorkspaceUserProfile()
has completed, and WorkspaceAppPreferences()
only starts after WorkspaceRecentActivity()
is done. If these three operations are independent, we're unnecessarily making the user wait for the sum of their durations. This "hidden cost" translates directly to longer loading times and a sluggish user experience.
Here's how we can fix it using Future.wait
:
// ✅ Awaiting independent futures concurrently with Future.wait
Future<void> loadDashboardDataConcurrently() async {
final Future<UserProfile> userProfileFuture = fetchUserProfile();
final Future<List<Activity>> recentActivityFuture = fetchRecentActivity();
final Future<AppPreferences> appPreferencesFuture = fetchAppPreferences();
final results = await Future.wait([
userProfileFuture, // Starts immediately
recentActivityFuture, // Starts immediately
appPreferencesFuture, // Starts immediately
]);
final results = await Future.wait(futures);
// Total time roughly: max(2s, 1.5s, 1s) = 2 seconds
// DO SOMETHING!
}
Here is the print output if you try
Starting concurrent dashboard data load...
User profile loaded at 2025-05-25 00:21:23.215
UserProfile instance
Recent activity loaded at 2025-05-25 00:21:23.216
Activity instance
App preferences loaded at 2025-05-25 00:21:23.216
Preferences instance
Concurrent dashboard load took: 2004ms
Concurrent Dashboard UI updated.
By using Future.wait()
, we initiate all independent operations roughly at the same time. The await Future.wait(futures)
line then pauses until all the Futures
in the futures list have completed. The total time taken is now dictated by the longest individual operation, not the sum of all operations. In our example, this cuts down the loading time from approximately 4.5 seconds to 2 seconds, a significant improvement!
The Future.wait()
method returns a Future
which completes with a List
containing the results of each Future
, in the same order they were provided in the input list.
// Results are in the same order as the Futures in the list
final userProfile = results[0] as UserProfile;
final recentActivity = results[1] as Activity;
final appPreferences = results[2] as Preferences;
DCM Rule
Using Future.wait
for multiple independent futures boosts performance. DCM helps ensure this tool is used correctly, for instance, its avoid-unnecessary-collections
rule flags Future.wait
on single futures (e.g., await Future.wait([oneFuture])
) or empty lists. This promotes direct awaits like await oneFuture
.
Misuse of FutureOr
FutureOr<T>
is a union type that can be either a Future<T>
or a plain T
. Misusing FutureOr
usually means treating it just like a Future
in all cases; in other words, always awaiting it. If the value was actually available synchronously, awaiting it forces an unnecessary asynchronous gap. This is a performance issue and can also lead to confusion in code flow.
Let's look at the example below:
FutureOr<int> maybeComputeValue() {
if (cache.hasValue) {
return cache.value; // returns an int directly
} else {
return fetchValueFromDB(); // returns a Future<int>
}
}
Future<void> doWork() async {
// ❌ Always awaiting a FutureOr without type check
int value = await maybeComputeValue();
use(value);
}
In doWork()
, we await maybeComputeValue()
blindly. If cache.hasValue
was true, maybeComputeValue()
returned a plain int synchronously but by awaiting it, we’ve forced it into a Future
. We’ve essentially "shadowed" an immediate value behind a Future
which is incurring an unnecessary delay and scheduling a task that yields right back. In a UI context, this might mean an extra build frame delay or jank.
Here how we can fix it:
Future<void> doWork() async {
final resultOrFuture = maybeComputeValue();
int value;
if (resultOrFuture is Future<int>) {
value = await resultOrFuture;
} else {
value = resultOrFuture; // ✅ already an int
}
use(value);
}
By checking the type, we only await when the result is truly a Future
. If we already have the value, we use it immediately. Alternatively, you could design maybeComputeValue()
to consistently return a Future
(always async) or always sync, but when using APIs you don’t control, handling both cases is important, especially on web that might often happen.
DCM Rule
DCM prefer-unwrapping-future-or
rule exists for this exact scenario. It will warn when you directly await a FutureOr
without first testing its type. The fix is what we showed above, unwrap the FutureOr
by an is Future
check. This static analysis hint can save you from unintended performance hiccups and ensure you don’t introduce avoidable latency in your UI updates.
Misused .then()
Chains
For this one, common issues are generally in two ways:
- Forgetting to handle errors (since
try/catch
doesn’t catch exceptions in a separate .then callback) - Creating deeply nested callbacks that are hard to read, or not returning a
Future
from inside a.then
resulting in anunawaited
inner future.
I think an example can show the clear problem:
// ❌ Callback hell with .then(), potential error swallowing
fetchUser().then((user) {
return fetchOrders(user.id).then((orders) {
setState(() {
// update UI with user and orders
profile = user;
recentOrders = orders;
});
});
}).catchError((e) {
// This catches errors from fetchUser, but not from inside the inner callback
showError(e);
});
In the above example, we nested a second .then
inside the first. If an error occurs in fetchOrders
or during the setState
call, that error is not caught by the outer catchError
, it will propagate as an unhandled exception because we didn’t attach a second catchError
for the inner chain. Another problem, using .then
inside a widget’s logic like this can unintentionally continue actions even if the widget was disposed similar to unawaited
futures. The code is also harder to read due to the nesting.
Let's refactor the code:
try {
final user = await fetchUser();
final orders = await fetchOrders(user.id);
if (!mounted) return; // ✅ ensure widget is still active
setState(() {
profile = user;
recentOrders = orders;
});
} catch (e) {
showError(e);
}
Using await
makes the flow linear and easier to understand. We can handle errors with a normal try/catch
, which will catch failures from either fetchUser()
or fetchOrders()
or even the setState()
if it throws. We also check mounted before calling setState
as a safety measure for widget lifecycle.
DCM Rule
There is a lint rule prefer-async-await
that recommends using async/await
over .then()
callbacks. This rule will be warning you to refactor callback chains into async functions.
Additional Async Pitfalls
Even with the most careful use of await, there are more async traps that can slip through unnoticed. Let’s look at four additional mistakes that Flutter developers frequently make.
Avoid Calling toString()
on a Future
When you have a Future
object and try to print it or use it in string interpolation, expecting to see the resolved value. Instead, you get something like Instance of '_Future<String>'
. This happens. This is because Dart’s default implementation of .toString()
on Future
doesn’t reflect the eventual value, just the type and instance.
While this might not crash your app, it leads to unhelpful logs and can make debugging much harder, as you're not seeing the data you expect.
Let's look at an example:
Future<String> getUserName() async {
await Future.delayed(Duration(seconds: 1));
return 'Majid';
}
void printUserName() {
final nameFuture = getUserName();
// ❌ Calling .toString() on the Future object itself
print('User: $nameFuture');
print('User: $nameFuture.toString()');
// Output: User: Instance of '_Future<String>'
}
// ✅ Correct approach
Future<void> printUserNameCorrectly() async {
final nameFuture = getUserName();
final name = await nameFuture; // ✅ Await the future
print('User: $name');
// Output: User: Majid
}
In printUserName()
, we're directly embedding nameFuture
into the string. In printUserNameCorrectly()
, we await the Future
to get the actual String
value before printing it.
DCM Rule
The DCM rule avoid-future-tostring
helps catch these instances. It flags direct calls to .toString()
on Future
objects or their use in string interpolations, guiding you to await the Future
first to access its resolved value.
Creating Futures for No Reason
Sometimes, a function is marked async and returns a Future
, but the work it does could be synchronous, or it wraps an already completed Future
for no good reason. While Dart's async functions automatically wrap returned non-Future values in Future.value()
, explicitly creating Futures when not needed adds slight overhead and can obscure the function's true nature.
Consider this example:
// ❌ Unnecessarily async
Future<int> getCachedCount() async {
if (_cache.containsKey('count')) {
return _cache['count'];
}
return 0;
}
// ❌ Unnecessarily async
Future<String> getImmediateMessage() async {
return 'Hello'; // Dart wraps this in Future.value('Hello') implicitly
}
The async keyword here unnecessarily creates a new Future
, even though the work is synchronous and the result is already known. In getImmediateMessage()
, the function is marked async just to return a direct value. Dart will automatically wrap that value in a Future
, so adding async only introduces unnecessary overhead and scheduling.
Here's how to make it more direct:
// ✅ Returns a Future directly without async overhead
Future<int> getCachedCount() {
if (_cache.containsKey('count')) {
return Future.value(_cache['count']);
}
return Future.value(0);
}
// ✅ If the function logic is purely synchronous
String getImmediateMessageSync() {
return 'Hello';
}
// ✅ If the function logic is purely synchronous and must return a Future
Future<String> getImmediateMessage() {
return Future.value('Hello');
}
// Or, if the function can sometimes be sync, sometimes async:
FutureOr<String> getMessage() {
if (condition) {
return 'Hello Sync'; // Returns String
}
return fetchMessageAsync(); // Returns Future<String>
}
In the case of FutureOr
make sure you follow the best practices that I mentioned in this article earlier.
DCM Rule
DCM's avoid-unnecessary-futures
rule identifies functions that are async but return a Future
literal (e.g., Future.value()
, Future.error()
, Future.sync()
) or a value that will be implicitly wrapped. It suggests removing the async keyword if a Future
is already being returned, or making the function synchronous if it doesn't perform any asynchronous operations.
Creating Futures Inside FutureBuilder
Instead of Passing an Existing One
FutureBuilder
is a great widget that rebuilds based on the state of a Future
, allowing you to reactively display loading, success, or error states. However, a common mistake is to provide a new Future
to its future parameter every time the build method runs. This typically happens when you call a method that returns a new Future
directly within the build method.
This leads to the Future
re-executing on every rebuild (e.g., triggered by setState, parent widget rebuilds, animations), causing redundant computations, API calls, and UI flickering as the FutureBuilder
cycles through connection states.
Here's a problematic example:
class _MyWidgetState extends State<MyWidget> {
Future<String> fetchData() async {
// Simulates a network call
await Future.delayed(const Duration(seconds: 2));
return "Data fetched at ${DateTime.now()}";
}
Widget build(BuildContext context) {
return FutureBuilder<String>(
// ❌ fetchData() is called on every build, creating a new Future
future: fetchData(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
return Text(snapshot.data ?? 'No data');
},
);
}
}
In the code above, fetchData()
is invoked every time _MyWidgetState
rebuilds. This means the 2-second delay and data fetching logic will run repeatedly.
The correct way is to obtain the Future
once and store it, typically in initState
or in response to an event, and then pass this stored Future
instance to FutureBuilder
.
class _MyWidgetState extends State<MyWidget> {
Future<String>? _dataFuture;
void initState() {
super.initState();
// ✅ Obtain the Future once
_dataFuture = _fetchData();
}
Future<String> _fetchData() async {
await Future.delayed(const Duration(seconds: 2));
return "Data fetched at ${DateTime.now()}";
}
void _reloadData() {
setState(() {
// ✅ If you need to re-fetch, create a new Future and update state
_dataFuture = _fetchData();
});
}
Widget build(BuildContext context) {
return Column(
children: [
FutureBuilder<String>(
future: _dataFuture, // ✅ Pass the existing Future instance
builder: (context, snapshot) {
// ... same builder logic
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
return Text(snapshot.data ?? 'No data');
},
),
ElevatedButton(onPressed: _reloadData, child: Text('Reload'))
],
);
}
}
By initializing _dataFuture
in initState()
and using that stored instance, FutureBuilder
works with the same Future
across rebuilds unless we explicitly decide to fetch new data (e.g., via _reloadData()
).
DCM Rule
The DCM rule pass-existing-future-to-future-builder
is designed to catch this specific misuse. It warns you if the future argument of a FutureBuilder
is likely receiving a newly created Future
on each build, guiding you to store and reuse the Future instance appropriately.
Unhandled Future Errors (Beyond Lifecycle Methods)
We touched upon unhandled errors with unawaited
futures in lifecycle methods, but the problem is broader. Any Future
that can complete with an error must have that error handled. If a Future
fails and no error handler is attached, for example, via await in a try-catch
block, or using .catchError()
, the error becomes an "uncaught asynchronous error."
These unhandled errors can silently corrupt application state or, depending on the Dart environment and error type, even crash the application. Relying on a global error handler (like PlatformDispatcher.instance.onError
) is a last resort, not a primary error handling strategy.
Consider this:
Future<void> performCriticalTask() async {
await Future.delayed(Duration(milliseconds: 100));
if (Random().nextBool()) {
throw Exception('Critical task failed!');
}
print('Critical task succeeded.');
}
void mainOperation() {
// ❌ Error from performCriticalTask is not handled
performCriticalTask();
print('Main operation continues...');
}
// ✅ Better approach:
Future<void> mainOperationHandled() async {
try {
await performCriticalTask();
print('Main operation with handled task continues...');
} catch (e) {
print('Caught error from critical task: $e');
}
}
// ✅ For "fire-and-forget" where you don't await, but still want to handle errors:
void fireAndForgetHandled() {
performCriticalTask().catchError((e) {
print('Caught error from fire-and-forget task: $e');
});
}
In mainOperation()
, if performCriticalTask()
throws an exception, it becomes an uncaught async error. mainOperationHandled()
demonstrates proper error handling using try-catch
with await. For Futures
you intentionally don't await (fire-and-forget), attaching a .catchError()
handler is crucial if the error is significant.
If the error is truly irrelevant and the future is intentionally not awaited, using unawaited(
) from dart:async
explicitly signals fire-and-forget intent. However, it does not catch or suppress errors so it's still your responsibility to attach a .catchError()
if the Future
might fail. To ensure safety, pair unawaited()
with .catchError()
if there’s any risk of failure.
DCM Rule
DCM avoid-uncaught-future-errors
rule helps identify Futures whose errors are not being handled. It encourages you to either await the Future
within a try-catch block or attach a .catchError()
handler to ensure that potential failures are managed gracefully, preventing them from becoming silent issues or causing unexpected crashes.
Wrap up
As Flutter developers, we deal with asynchronous code every day; it’s unavoidable. But the difference between flawless async code and buggy async code is often just a matter of a missing await or a misused callback.
These mistakes are easy to make and hard to debug, because they usually don’t announce themselves loudly. We’ve seen how unawaited
futures, redundant awaits, misuse of FutureOr
, and wrong .then()
chaining can lead to memory leaks, performance issues, UI jank, and more.
The good news is that with the right practices and tools, we can avoid most of these pitfalls. DCM, leading the code quality in Flutter world, tends to bring innovation and tools that makes these even easier to catch and fix faster. For example, recent DCM announcements about DCM Dashboards are clearly show how we are trying to help teams to have a better control over their code quality even if they forgot something unintentionally.
Sharing your feedback
Do you have a feature request? Have questions or suggestions? Please reach out via email [email protected] or on our Discord server.