← Back to posts Cover image for Graceful Cancellation: Mastering Asynchronous Operations with Futures and Streams in Flutter

Graceful Cancellation: Mastering Asynchronous Operations with Futures and Streams in Flutter

· 8 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

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: For StatefulWidgets, always check if (mounted) before calling setState after an await or Future.then. Otherwise, you risk calling setState on a widget that’s already been disposed.
  • Neglecting dispose(): dispose() is your best friend for cleanup. Any StreamSubscription, CancelToken, or even Timer should be cancelled/closed in dispose().
  • Over-complicating: For simple cases, a bool flag 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-catch blocks 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

Cover image for Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations

Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations

Developers often face performance bottlenecks when performing expensive operations like date formatting directly within Flutter's `build` method, especially in fast-scrolling lists. This post will delve into common pitfalls, explain why these operations are costly, and provide practical strategies for optimizing UI performance by caching formatters, using `initState`, and leveraging `compute` for background processing without blocking the UI.

Cover image for Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity

Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity

Flutter developers frequently seek to refine their development environments. This post will dive into popular IDE choices like VS Code and Android Studio, discuss best practices for managing iOS and Android simulators (including in-IDE options), and explore the practical integration of AI tools for code generation and problem-solving to boost overall efficiency.

Cover image for Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps

Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps

Flutter's performance is often blamed for issues in complex applications, but the real culprits are usually architectural decisions, inefficient widget rebuilds, and unoptimized resource handling. This post will dive into common performance bottlenecks in large Flutter apps, providing actionable strategies for profiling, optimizing state management, handling images and network requests efficiently, and leveraging CI/CD for continuous performance monitoring.