Flutter 3.24 has introduced several exciting enhancements to its asynchronous programming model, which is vital for handling tasks such as network requests, file I/O, and database queries without blocking the UI. In this blog post, we’ll dive into the asynchronous code improvements in Flutter 3.24, exploring what’s new, how it works, and why it matters for your app development process.
1. Enhanced Stream and Future Handling in Flutter 3.24
Futures and Streams are the foundation of Dart async await code. A Future represents a single value that arrives later, while a Stream delivers a sequence of values over time, such as live prices, chat messages or sensor data.
a. Optimized Future Scheduling
Dart runs on a single-threaded event loop. Every await hands control back to the loop, so the UI can keep drawing frames while a network call is in flight. The practical rule for good Future handling is to keep each event-loop turn short and to run independent work in parallel instead of one call after another.
A modern way to do this is Future.wait, or the record-based .wait introduced with Dart 3:
Future<void> loadDashboard() async {
// Both requests start immediately and run concurrently.
final (profile, orders) = await (fetchProfile(), fetchOrders()).wait;
print('Profile: $profile, orders: ${orders.length}');
}
This pattern cuts total wait time to the slowest request instead of the sum of both, which is one of the cheapest Flutter performance optimization wins available.
b. Stream Enhancements
Streams are ideal for continuous data such as live feeds, chats and financial dashboards. Use async* generators to produce values, and always cancel subscriptions you no longer need.
Example: Efficient Stream Handling
Stream<int> numberStream() async* {
for (int i = 0; i < 10; i++) {
await Future.delayed(const Duration(seconds: 1));
yield i;
}
}
StreamSubscription<int>? _sub;
void handleStream() {
_sub = numberStream().listen((number) {
print('New number: $number');
});
}
void dispose() {
_sub?.cancel(); // Prevents leaks and callbacks after the widget is gone.
}
Cancelling the subscription in dispose() is the single most common fix for memory leaks in Flutter stream code.
2. Improved Error Handling in Asynchronous Code
Flutter stream error handling and Future error handling are where many apps quietly fail. An unhandled exception inside an async function does not crash the app immediately, but it can leave your UI stuck on a loading spinner or silently drop data. The fix is to handle errors close to where they happen and to add a global safety net.
Example: Improved Error Handling
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<Map<String, dynamic>> fetchData() async {
try {
final response = await http
.get(Uri.parse('https://example.com/data'))
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Failed to load data (${response.statusCode})');
}
return jsonDecode(response.body) as Map<String, dynamic>;
} on TimeoutException {
throw Exception('The request timed out. Please try again.');
} catch (e) {
rethrow; // Let the caller or state layer decide how to show it.
}
}
For streams, handle errors with onError or handleError, and decide whether a failure should end the stream:
numberStream().listen(
(n) => print(n),
onError: (Object e, StackTrace s) => print('Stream error: $e'),
cancelOnError: false,
);
Add a global safety net in main() so anything that slips through is reported rather than lost:
void main() {
FlutterError.onError = (details) {
// Send to your crash reporter.
};
PlatformDispatcher.instance.onError = (error, stack) {
// Report async errors that escape zones.
return true;
};
runApp(const MyApp());
}
3. Concurrency with Isolates: Easier and Faster Background Tasks
Dart is single-threaded per isolate, so a heavy loop on the main isolate will freeze the UI. Isolates give you true parallelism for Flutter background processing, such as image processing, encryption, large JSON parsing or data compression. Each isolate has its own memory and communicates by message passing.
For one-off jobs, Isolate.run() (available since Dart 2.19) is now the simplest option. It spawns an isolate, runs your function, returns the result and shuts the isolate down.
Example: Using Isolates
import 'dart:convert';
import 'dart:isolate';
List<dynamic> decodeHeavyJson(String raw) => jsonDecode(raw) as List<dynamic>;
Future<List<dynamic>> parseInBackground(String raw) async {
return Isolate.run(() => decodeHeavyJson(raw));
}
If you need a long-lived worker with two-way communication, use Isolate.spawn with a ReceivePort and SendPort, and close the ports when you are finished:
import 'dart:isolate';
void heavyTask(SendPort sendPort) {
int result = 0;
for (int i = 0; i < 1000000000; i++) {
result += i;
}
sendPort.send(result);
}
Future<void> runHeavyTask() async {
final receivePort = ReceivePort();
await Isolate.spawn(heavyTask, receivePort.sendPort);
final result = await receivePort.first;
print('Heavy task result: $result');
receivePort.close();
}
A simple decision rule: if work is quick (well under one 16 ms frame), keep it on the main isolate with async/await. If it is CPU-heavy, move it to an isolate. Flutter’s compute() is still available as a thin wrapper, but Isolate.run() is the more flexible choice in modern Dart.
4. Async-Aware Widgets: Smoother Loading States
FutureBuilder and StreamBuilder connect async data to your widget tree. The most important FutureBuilder optimization is also the simplest: never create the Future inside build(). If you do, every rebuild starts a fresh request, causing flicker and wasted network calls.
Example: Async-Aware UI with FutureBuilder
class AsyncDataWidget extends StatefulWidget {
const AsyncDataWidget({super.key});
@override
State<AsyncDataWidget> createState() => _AsyncDataWidgetState();
}
class _AsyncDataWidgetState extends State<AsyncDataWidget> {
late final Future<String> _future = fetchData();
Future<String> fetchData() async {
await Future.delayed(const Duration(seconds: 2));
return 'Data loaded!';
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
return Text('Result: ${snapshot.data}');
},
);
}
}
FutureBuilder vs StreamBuilder: use FutureBuilder for a one-time load, and StreamBuilder when data keeps changing, such as a Firestore collection or a WebSocket feed. In larger apps, many teams move async loading into a state layer (Riverpod, Bloc or similar) and let widgets simply render loading, error and data states. That keeps widgets easy to test and avoids duplicated fetches.
5. Concurrency with async/await: More Predictable Execution
Dart async await code reads like synchronous code, but it can still produce race conditions. A classic case is a search box where a slow earlier response arrives after a newer one and overwrites the correct results.
A simple guard is to track the latest request:
int _requestId = 0;
Future<void> search(String query) async {
final id = ++_requestId;
final results = await api.search(query);
if (id != _requestId) return; // A newer search started; ignore this result.
if (!mounted) return; // The widget was disposed while waiting.
setState(() => _results = results);
}
A few habits make async/await execution far more predictable:
- Always check
mountedafter an await before touchingBuildContextor callingsetState. - Run independent calls in parallel with
Future.waitand sequence only the calls that truly depend on each other. - Enable the
unawaited_futuresanduse_build_context_synchronouslylints so mistakes are caught at analysis time. - Avoid calling async methods directly in
initStatewithout handling errors and disposal.
Conclusion : Why Asynchronous Code Improvements in Flutter 3.24 Matter
Good Flutter async programming is the difference between an app that feels instant and one that stutters. From Flutter 3.24 through today’s Flutter 3.47 and Dart 3.13, the toolkit is clear: use Future and Stream for I/O, Isolate.run for heavy computation, FutureBuilder and StreamBuilder (or a state layer) for loading states, and disciplined error handling plus async/await guards for predictability. Apply these patterns and your app stays smooth under real-world load.
Need help building or optimizing a Flutter app? The team at 200OK Solutions designs and ships custom mobile software. Talk to us.
