← Back to posts Cover image for Mastering Asynchronous Operations in Flutter: Cancelling Futures, Streams, and HTTP Requests

Mastering Asynchronous Operations in Flutter: Cancelling Futures, Streams, and HTTP Requests

· 4 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

Asynchronous operations are the lifeblood of responsive Flutter apps. They keep our UIs smooth while we fetch data, wait for timers, or process streams. However, a common pitfall is forgetting to clean up these operations when they’re no longer needed—like when a user navigates away from a screen before a network request finishes. Left unchecked, these orphaned tasks waste memory, drain batteries, and can cause subtle, hard-to-debug errors. Let’s master the art of graceful cancellation.

The Core Principle: CancelToken and StreamSubscription

Dart’s Future itself is not inherently cancellable. Once fired, it will attempt to resolve. The key is to not await futures we might need to cancel, or to wrap them in a mechanism that allows us to ignore their result.

For any ongoing process, you need a handle to cancel it. For streams, this is a StreamSubscription. For more complex operations like HTTP requests, we often use a CancelToken pattern.

1. Cancelling Simple Futures and Delays

Future.delayed is a classic example. You start a timer for a splash screen, but the user logs in instantly. You need to cancel that navigation delay.

Don’t do this: Once awaited, you’re committed.

// This cannot be stopped
await Future.delayed(Duration(seconds: 3));
navigateToHome();

Do this: Keep a reference to the Future and use a Completer or a flag.

import 'dart:async';

class DelayManager {
  Future? _pendingDelay;
  bool _cancelled = false;

  void startDelayedOperation() {
    _cancelled = false;
    _pendingDelay = Future.delayed(Duration(seconds: 3), () {
      if (!_cancelled) {
        print('Operation completed!');
      }
    });
  }

  void cancel() {
    _cancelled = true;
    _pendingDelay = null;
    print('Delay cancelled.');
  }
}

// Usage in a StatefulWidget's state:
// DelayManager _delayManager = DelayManager();
// In initState: _delayManager.startDelayedOperation();
// In dispose: _delayManager.cancel();

2. Mastering Stream Cancellation

Streams are a core Dart feature, and every listen() call returns a StreamSubscription—your cancellation handle.

The Golden Rule: Always cancel your subscriptions in dispose().

import 'dart:async';

class DataBloc {
  final StreamController<int> _controller = StreamController<int>.broadcast();
  StreamSubscription<int>? _dataSubscription;
  StreamSubscription<int>? _uiSubscription;

  void startListening() {
    // Subscribe to an external stream
    _dataSubscription = someExternalStream.listen(
      (data) => _controller.add(data * 2),
      onError: _controller.addError,
      onDone: () => print('External stream done'),
    );

    // Listen for UI updates
    _uiSubscription = _controller.stream.listen(
      (processedData) => print('UI received: $processedData'),
    );
  }

  void dispose() {
    // Cancel subscriptions in reverse order of creation
    _uiSubscription?.cancel();
    _dataSubscription?.cancel();
    _controller.close(); // Also close the controller
  }
}

Important: StreamController.close() does not cancel upstream subscriptions. Always cancel the StreamSubscription from the original source.

3. Cancelling HTTP Requests

The http package’s basic functions don’t support cancellation. For robust apps, use the dio package which has built-in CancelToken support.

Using Dio:

import 'package:dio/dio.dart';

class ApiService {
  final Dio _dio = Dio();
  CancelToken? _cancelToken;

  Future<Response> fetchUserData(String userId) async {
    // Create a new CancelToken for this request
    _cancelToken = CancelToken();
    try {
      return await _dio.get(
        'https://api.example.com/users/$userId',
        cancelToken: _cancelToken,
      );
    } catch (e) {
      if (DioExceptionType.cancel == e.type) {
        print('Request was cancelled voluntarily.');
      }
      rethrow;
    }
  }

  void cancelRequest() {
    _cancelToken?.cancel('User navigated away');
    _cancelToken = null;
  }
}

// In your Widget's dispose():
// apiService.cancelRequest();

4. The async* Generator and await for Loop

When using await for with a stream from an async* function, you break out of the loop by cancelling the underlying stream subscription.

Stream<int> countNumbers(CancelSignal cancelSignal) async* {
  for (int i = 0; i < 1000000; i++) {
    // Check for cancellation before yielding each item
    if (cancelSignal.isCancelled) break;
    yield i;
    await Future.delayed(Duration(milliseconds: 100));
  }
}

// Usage
class CancelSignal {
  bool isCancelled = false;
}

final signal = CancelSignal();
final stream = countNumbers(signal);
final subscription = stream.listen(print);

// Later, to cancel:
signal.isCancelled = true;
subscription.cancel(); // This will also break the async* function.

Best Practices for Flutter Widgets

  1. Use StatefulWidget for any screen that initiates async operations.
  2. Initialize subscriptions in initState().
  3. Cancel in dispose(). This is non-negotiable.
  4. Use mounted checks (with caution) if you schedule a post-async task that calls setState.
    Future<void> fetchData() async {
      final data = await apiService.getData();
      if (!mounted) return; // Widget is no longer on screen.
      setState(() => _data = data);
    }
  5. Consider the Bloc or Riverpod patterns. These state management libraries have built-in lifecycle management that often handles stream cancellation for you when a provider is disposed.

By proactively managing the lifecycle of your asynchronous operations, you’ll build Flutter apps that are more memory-efficient, responsive, and free from a whole class of subtle state-related bugs. Always ask yourself: “If this widget disappears, does everything it started clean up after itself?”

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Flutter App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB

Flutter App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB

Flutter apps are often criticized for their larger-than-native binary sizes. This post will dive into practical, actionable strategies to significantly reduce your Flutter app's footprint, including build configurations, asset optimization, and dependency management, demonstrating how to achieve app sizes under 10MB.

Cover image for Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls

Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls

Implementing in-app subscriptions in Flutter can be surprisingly complex, especially when dealing with cross-platform nuances, receipt validation, and managing different store configurations. This post will delve into the common challenges developers face, compare solutions like RevenueCat with custom implementations, and provide best practices for setting up and maintaining a robust subscription system.

Cover image for Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter

Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter

Android emulators are notorious for hogging RAM, often pushing developer machines into swap and slowing down Flutter development. This post will explore the reasons behind high emulator memory consumption and provide practical, actionable strategies and tools (including a custom-built solution) to significantly reduce RAM usage, ensuring a smoother development experience without sacrificing functionality.