Introduction
Async Dart code is the difference between an app that feels responsive and one that freezes mid-interaction. Whether you're fetching user data, listening to real-time GPS coordinates, or streaming messages from a WebSocket, you need to understand when to reach for Future and when to use Stream. This guide walks you through Async Dart: Future and Stream — best practices explained, showing you how to write non-blocking code that scales from a simple API call to a production ride-hailing system handling thousands of concurrent events.
Understanding Future: A single async result
A Future in Dart is like a Promise in JavaScript — it represents a value that will exist sometime in the future. It completes exactly once, either with a success (returning a value) or a failure (throwing an error). Most of your async work in Flutter starts here: API calls, file I/O, database queries.
When you call an async function, Dart returns a Future immediately. The actual work happens on the event loop, and when it's done, listeners get notified. This non-blocking behavior is what keeps your UI smooth.
Consider a CRM system fetching a list of leads. Without async, the app would freeze while waiting for the network. With Future, the UI thread stays responsive:
Future<List<Lead>> fetchLeads() async {
final res = await dio.get('/leads');
return (res.data as List)
.map((j) => Lead.fromJson(j))
.toList();
}In your widget, you'd consume this with a FutureBuilder:
FutureBuilder<List<Lead>>(
future: fetchLeads(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, i) {
return LeadTile(snapshot.data![i]);
},
);
},
)Key insight: call fetchLeads() once in build(), then pass it to FutureBuilder. Calling it in build repeatedly will trigger unnecessary network requests. For refresh logic, use a state variable or a callback.
async/await: Reads like synchronous code
The async and await keywords let you write async code that looks sequential. This is crucial for readability and maintainability in production systems.
When you mark a function as async, it automatically returns a Future. When you await another Future inside it, execution pauses until that Future completes, then resumes with the result. No callbacks, no chaining.
Real-world example: a travel app booking a hotel. You need to fetch availability, then check pricing, then lock a room:
Future<BookingResult> bookHotel(String hotelId, DateTime checkIn) async {
try {
// Step 1: Check availability
final availability = await checkAvailability(hotelId, checkIn);
if (!availability.available) {
throw BookingException('No rooms available');
}
// Step 2: Get price (only if available)
final price = await getPrice(hotelId, checkIn);
// Step 3: Lock the room
final booking = await lockRoom(hotelId, checkIn, price);
return booking;
} on DioException catch (e) {
rethrow; // Network error, rethrow for caller to handle
} on BookingException catch (e) {
return BookingResult.failed(e.message);
}
}Compare this to chained Futures (the old way):
// Without async/await — harder to read, easier to mess up
Future<BookingResult> bookHotel(String hotelId, DateTime checkIn) {
return checkAvailability(hotelId, checkIn)
.then((availability) {
if (!availability.available) throw BookingException('No rooms');
return getPrice(hotelId, checkIn);
})
.then((price) {
return lockRoom(hotelId, checkIn, price);
})
.catchError((e) {
// Error handling is split, harder to reason about
});
}Async/await is the modern, maintainable approach. It's also easier to debug — the stack trace shows the actual line that failed, not some internal Future machinery.
Stream: A sequence of async events
A Stream is for many values over time. Where a Future gives you one result once, a Stream gives you a sequence of results — or errors, or completion — over time. Think WebSockets, GPS location updates, real-time chat messages, or database change notifications.
A Stream can be listened to by multiple listeners (if it's a broadcast stream), and you can pause, resume, or cancel the subscription. This is essential for efficiency — you only receive data when you're actually watching.
Example: a ride-hailing app tracking driver location in real-time:
Stream<LatLng> watchDriverLocation(String driverId) {
return Stream.periodic(Duration(seconds: 2), (_) async {
final response = await dio.get('/drivers/$driverId/location');
return LatLng(
response.data['latitude'],
response.data['longitude'],
);
}).asyncMap((event) => event);
}Or, more realistically, via a WebSocket:
Stream<LatLng> watchDriverLocation(String driverId) {
return WebSocketChannel.connect(
Uri.parse('wss://api.example.com/drivers/$driverId/location'),
)
.stream
.map((json) {
final decoded = jsonDecode(json);
return LatLng(decoded['lat'], decoded['lng']);
});
}In your widget, use StreamBuilder:
StreamBuilder<LatLng>(
stream: watchDriverLocation(driverId),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Text('Waiting for location...');
}
return GoogleMap(
initialCameraPosition: CameraPosition(
target: snapshot.data!,
zoom: 15,
),
);
},
)Streams are powerful because they're composable. You can filter, map, buffer, and merge streams before building widgets:
// Only update map when location changes by more than 10 meters
stream
.where((newLoc) => newLoc.distanceTo(lastLoc) > 10)
.listen((location) {
updateMap(location);
});
// Buffer location updates and emit every 5 seconds
stream
.bufferTime(Duration(seconds: 5))
.listen((locations) {
recordRoute(locations);
});Cancel subscriptions: Cleanup matters
This is where many Flutter developers go wrong. Always cancel StreamSubscriptions when the widget is disposed. Failing to do so leaks memory, keeps network connections open, and wastes battery on GPS or sensor streams.
Store the subscription and cancel it in dispose():
class DriverMapScreen extends StatefulWidget {
@override
State<DriverMapScreen> createState() => _DriverMapScreenState();
}
class _DriverMapScreenState extends State<DriverMapScreen> {
late StreamSubscription<LatLng> _locationSubscription;
@override
void initState() {
super.initState();
_locationSubscription = watchDriverLocation(driverId)
.listen((location) {
setState(() {
_driverLocation = location;
});
});
}
@override
void dispose() {
_locationSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _driverLocation == null
? Center(child: CircularProgressIndicator())
: GoogleMap(initialCameraPosition: ...),
);
}
}If you have multiple subscriptions, cancel them all:
List<StreamSubscription> _subscriptions = [];
@override
void initState() {
super.initState();
_subscriptions.add(locationStream.listen(...));
_subscriptions.add(speedStream.listen(...));
_subscriptions.add(batteryStream.listen(...));
}
@override
void dispose() {
for (var sub in _subscriptions) {
sub.cancel();
}
super.dispose();
}Alternatively, use a helper like StreamGroup or a package like get_it for centralized subscription management in larger apps.
Common mistakes
- Calling async functions repeatedly in build(): A
FutureBuilderthat callsfetchData()directly will re-fetch on every rebuild. Store the Future in initState or use a controller instead. - Forgetting to cancel subscriptions: Memory leaks and open connections kill battery life and crash apps in production. Cancel in dispose().
- Using Future.delayed() for retries: If your API call fails, don't just delay and hope. Use exponential backoff and retry logic, or a package like
retry. - Not handling errors: Always add error handlers to StreamBuilder and FutureBuilder. Users need to know why data didn't load.
- Mixing await and .then(): Choose one style per function and stick to it. Mixing makes code hard to follow.
Key takeaways
- Use Future for single async results (API calls, file I/O); use Stream for sequences of values over time (WebSockets, GPS, sensors).
- async/await is the modern syntax for Futures — cleaner, more readable, easier to debug than callback chains.
- FutureBuilder for Futures, StreamBuilder for Streams — they handle connection states and errors for you.
- Always cancel StreamSubscriptions in dispose() to avoid memory leaks and keep your app performant.
- Compose Streams with
.map(),.where(),.bufferTime(), and other operators to build complex, efficient async logic.






Comments
Loading comments…