The app had a dashboard screen that fetched multiple APIs in parallel — user profile, notifications, and analytics data — and displayed them together. Occasionally, after navigating back and forth between screens, the UI showed mismatched data — for example, the notification count belonged to a different user session.
At first glance, it looked like a backend issue, but after logging responses, I realized that the API data was correct. So the problem had to be on the client side — likely a race condition or stale state in Dart.
I started debugging by adding detailed logs with timestamps around the async calls. That revealed that one of the futures was completing after the widget had been disposed, but its setState() was still trying to update the UI. This was causing the old data to flash briefly on the screen before the new state rebuilt.
To confirm, I wrapped my setState() calls like this:
if (mounted) {
setState(() {
_data = fetchedData;
});
}
That fixed the immediate crash, but the data mismatch still occasionally happened.
Next, I turned to Dart DevTools’ Timeline view to trace async events. It showed that the API calls were completing out of order — since I was firing multiple Futures in parallel with Future.wait(), but some had network retries. The dashboard update logic didn’t account for this, so whichever call finished last was overwriting shared state.
I solved this by restructuring the async logic to await critical futures in sequence while keeping others parallelized. I also separated each API’s state into its own model instead of a single shared Map. That made the state updates isolated and predictable.
Here’s the final pattern I used:
final userFuture = fetchUser();
final notificationFuture = fetchNotifications();
final user = await userFuture;
final notifications = await notificationFuture;
setState(() {
userData = user;
notificationData = notifications;
});
This ensured consistent ordering and no shared mutation.
One challenge I faced during this debugging was that the issue was non-deterministic — it only occurred under poor network conditions. So I used Flutter’s Network Link Conditioner and simulated latency to reproduce it reliably. That was a game-changer — I could finally step through the issue with breakpoints and isolate the race condition.
In the process, I also learned the importance of canceling old async operations when navigating away from screens. I later added cancellation tokens to stop API calls when the widget was disposed, preventing stale data from arriving after navigation.
The biggest takeaway from that experience was that debugging async issues in Dart requires a structured approach — logs, profiling, and isolation of async flows. Once I broke the problem down into event timing and state ownership, it became much easier to reason about.
So, in short — it was a tough bug caused by overlapping async tasks and state updates, but by combining logging, DevTools analysis, and better async management (with mounted checks and state isolation), I fixed it permanently and made the codebase far more stable.
