Graceful Cancellation: Mastering Asynchronous Operations with Futures and Streams in Flutter
The Flutter news you actually need
No spam, ever. Unsubscribe in one click.
Asynchronous programming is at the very core of building responsive and efficient Flutter applications. We’re constantly dealing with operations that take time: fetching data from an API, reading from a database, or simply waiting for a user interaction. While async/await and Futures make these operations a breeze to write, there’s a crucial aspect often overlooked: cancellation.
Imagine kicking off a long-running task, and then the user navigates away from the screen that initiated it. If that task isn’t cancelled, it might continue running in the background, consuming resources, potentially causing memory leaks, or even trying to update a UI that no longer exists, leading to runtime errors. Graceful cancellation isn’t just good practice; it’s essential for robust Flutter apps.
Let’s dive into how we can master cancellation for common asynchronous patterns.
The Nature of Futures and Cancellation
First, a quick clarification: In Dart, a Future itself isn’t directly cancellable. Once a Future is created and started, it will eventually complete with a value or an error. What we can cancel or manage is the effect of that Future completing. We achieve this by checking conditions before acting on the Future’s result, or by using specific mechanisms provided by certain packages.
1. Cancelling Future.delayed and Time-Based Operations
Future.delayed is often used for simple timers or to defer an action. The Future created by Future.delayed will always complete after its specified duration. Our goal is to prevent the code inside its then block (or after its await) from executing if we’ve decided to cancel.
The simplest way to achieve this is with a bool flag.
import 'package:flutter/material.dart';
class DelayedActionScreen extends StatefulWidget {
@override
_DelayedActionScreenState createState() => _DelayedActionScreenState();
}
class _DelayedActionScreenState extends State<DelayedActionScreen> {
bool _isActionPending = false;
String _message = "Waiting...";
void _startDelayedAction() {
setState(() {
_message = "Action pending in 3 seconds...";
_isActionPending = true;
});
Future.delayed(Duration(seconds: 3), () {
if (_isActionPending && mounted) { // Check flag AND widget mounted
setState(() {
_message = "Action completed!";
_isActionPending = false;
});
} else if (!mounted) {
print("Widget no longer mounted, delayed action ignored.");
} else {
print("Delayed action was cancelled.");
}
});
}
void _cancelDelayedAction() {
setState(() {
_isActionPending = false;
_message = "Action cancelled!";
});
}
@override
void dispose() {
_isActionPending = false; // Ensure pending actions are cancelled on dispose
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Delayed Action")),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_message, style: TextStyle(fontSize: 20)),
SizedBox(height: 20),
ElevatedButton(
onPressed: _isActionPending ? null : _startDelayedAction,
child: Text("Start Delayed Action"),
),
SizedBox(height: 10),
ElevatedButton(
onPressed: _isActionPending ? _cancelDelayedAction : null,
child: Text("Cancel Action"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
),
],
),
),
);
}
}
Here, _isActionPending acts as our cancellation flag. Before updating the UI, we check if the flag is still true. We also add mounted check for StatefulWidgets, which is crucial to prevent setState calls on a disposed widget.
2. Cancelling Network Requests
Network requests are a prime candidate for cancellation, especially in scenarios like search bars (where previous requests become stale) or navigating away from a screen.
Using http Package (Manual Cancellation)
The standard http package doesn’t offer built-in cancellation. You’ll use the same bool flag approach as with Future.delayed.
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class HttpRequestScreen extends StatefulWidget {
@override
_HttpRequestScreenState createState() => _HttpRequestScreenState();
}
class _HttpRequestScreenState extends State<HttpRequestScreen> {
bool _isRequestActive = false;
String _data = "No data yet.";
Future<void> _fetchData() async {
setState(() {
_isRequestActive = true;
_data = "Fetching data...";
});
try {
final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts/1'));
if (_isRequestActive && mounted) { // Check flag and mounted
if (response.statusCode == 200) {
setState(() {
_data = json.decode(response.body)['title'];
});
} else {
setState(() {
_data = "Failed to load data: ${response.statusCode}";
});
}
} else if (!mounted) {
print("Widget no longer mounted, HTTP request result ignored.");
} else {
print("HTTP request was cancelled, result ignored.");
}
} catch (e) {
if (_isRequestActive && mounted) {
setState(() {
_data = "Error: $e";
});
}
} finally {
if (mounted) { // Always update _isRequestActive if widget is still around
setState(() {
_isRequestActive = false;
});
}
}
}
void _cancelRequest() {
setState(() {
_isRequestActive = false;
_data = "Request cancelled by user.";
});
}
@override
void dispose() {
_isRequestActive = false; // Important for ongoing requests
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("HTTP Request")),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_data, style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
SizedBox(height: 20),
ElevatedButton(
onPressed: _isRequestActive ? null : _fetchData,
child: Text("Fetch Data"),
),
SizedBox(height: 10),
ElevatedButton(
onPressed: _isRequestActive ? _cancelRequest : null,
child: Text("Cancel Request"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
),
],
),
),
);
}
}
Using Dio Package (Built-in Cancellation)
For more advanced HTTP clients like Dio, cancellation is a first-class feature using CancelToken. This is often preferred as it can actually abort the underlying network connection, saving bandwidth and server resources.
First, add dio to your pubspec.yaml.
dependencies:
flutter:
sdk: flutter
dio: ^5.x.x # Check pub.dev for the latest stable version
Then, implement cancellation:
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
class DioRequestScreen extends StatefulWidget {
@override
_DioRequestScreenState createState() => _DioRequestScreenState();
}
class _DioRequestScreenState extends State<DioRequestScreen> {
Dio _dio = Dio();
CancelToken? _cancelToken; // Nullable to indicate no active request
String _data = "No data yet.";
Future<void> _fetchDataWithDio() async {
setState(() {
_data = "Fetching data with Dio...";
_cancelToken = CancelToken(); // Create a new token for this request
});
try {
final response = await _dio.get(
'https://jsonplaceholder.typicode.com/todos/1',
cancelToken: _cancelToken, // Pass the token to the request
);
if (mounted) { // Always check mounted before setState
setState(() {
_data = response.data['title'];
});
}
} on DioException catch (e) {
if (CancelToken.isCancel(e)) {
if (mounted) {
setState(() {
_data = "Dio request cancelled.";
});
}
} else {
if (mounted) {
setState(() {
_data = "Dio Error: ${e.message}";
});
}
}
} finally {
if (mounted) {
setState(() {
_cancelToken = null; // Clear token after request completes or is cancelled
});
}
}
}
void _cancelDioRequest() {
_cancelToken?.cancel("Request explicitly cancelled by user."); // Cancel if token exists
}
@override
void dispose() {
_cancelToken?.cancel("Widget disposed."); // Cancel any pending requests on dispose
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Dio Request")),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_data, style: TextStyle(fontSize: 20), textAlign: TextAlign.center),
SizedBox(height: 20),
ElevatedButton(
onPressed: _cancelToken == null ? _fetchDataWithDio : null,
child: Text("Fetch Data with Dio"),
),
SizedBox(height: 10),
ElevatedButton(
onPressed: _cancelToken != null ? _cancelDioRequest : null,
child: Text("Cancel Dio Request"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
),
],
),
),
);
}
}
3. Cancelling Stream Subscriptions
Streams provide a sequence of asynchronous events. If you subscribe to a stream and don’t cancel the subscription, it will continue emitting events even if the listener (e.g., a StatefulWidget) is no longer active. This is a classic source of memory leaks.
The solution is straightforward: store the StreamSubscription and call its cancel() method when appropriate, typically in dispose().
import 'dart:async';
import 'package:flutter/material.dart';
class StreamScreen extends StatefulWidget {
@override
_StreamScreenState createState() => _StreamScreenState();
}
class _StreamScreenState extends State<StreamScreen> {
StreamSubscription<int>? _subscription;
int _currentCount = 0;
@override
void initState() {
super.initState();
_startCounterStream();
}
void _startCounterStream() {
// Create a simple stream that emits a number every second
Stream<int> counterStream = Stream.periodic(Duration(seconds: 1), (count) => count);
_subscription = counterStream.listen((data) {
if (mounted) { // Always check mounted before setState
setState(() {
_currentCount = data;
});
}
}, onError: (error) {
print("Stream error: $error");
}, onDone: () {
print("Stream done.");
});
}
@override
void dispose() {
_subscription?.cancel(); // Crucial: cancel the subscription
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Stream Cancellation")),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("Count: $_currentCount", style: TextStyle(fontSize: 24)),
SizedBox(height: 20),
Text("Navigate away from this screen quickly to prevent further UI updates and resource consumption from the stream."),
],
),
),
);
}
}
In this example, the _subscription?.cancel() call in dispose() ensures that when the StreamScreen widget is removed from the widget tree, the subscription to counterStream is properly closed, preventing further events from being processed.
Common Pitfalls and Best Practices
- Forgetting
mounted: ForStatefulWidgets, always checkif (mounted)before callingsetStateafter anawaitorFuture.then. Otherwise, you risk callingsetStateon a widget that’s already been disposed. - Neglecting
dispose():dispose()is your best friend for cleanup. AnyStreamSubscription,CancelToken, or evenTimershould be cancelled/closed indispose(). - Over-complicating: For simple cases, a
boolflag is perfectly fine. Don’t reach for complex patterns if a simple flag can do the job effectively. - Not handling errors: Cancellation is about graceful shutdown, but robust code also handles errors during the operation itself. Always include
try-catchblocks where appropriate.
By understanding these techniques and consistently applying them, you’ll build more resilient, efficient, and user-friendly Flutter applications. Happy coding!
This blog is produced with the assistance of AI by a human editor. Learn more
Related Posts
Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?
Developers are curious about Flutter's capabilities beyond typical business apps, especially for demanding desktop applications like CAD/CAM or image/video processing. This post will explore Flutter's suitability for high-performance, viewport-based desktop GUIs, discussing Dart's memory model, the 60fps update loop, and real-world examples to gauge its readiness for 'serious' complex software.
Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug
Flutter web applications often suffer from a frustrating 'deep link refresh bug' where refreshing the browser on a nested route (e.g., /home/details) bounces the user back to the root or an incorrect path. This post will diagnose the common causes of this issue, explain how Flutter's router handles web URLs, and provide practical solutions and best practices for building robust, refresh-proof navigation in your Flutter web apps.
Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps
As Flutter applications grow, managing strings for multiple languages or just keeping text consistent becomes a challenge. This post will guide developers through effective strategies for centralizing strings, implementing robust internationalization (i18n) and localization (l10n), and leveraging tools to streamline the process for small to large-scale projects.