skip to content

With dio in Flutter, how does a CancelToken cancel in-flight requests, and what goes wrong if you reuse a token after cancelling it?

level: middleimportance: should knowfreq 36%

answer

  1. one token, many requests
  2. cancel completes whenCancel
  3. DioExceptionType.cancel
  4. cancelled tokens stay cancelled
  5. http has abortTrigger since 1.5

basics

~20 s

Pass a CancelToken to requests and call cancel() to abort them; each throws a DioException of type cancel. A cancelled token stays cancelled, so any later request using it fails immediately; create a new token per screen or search.

solid answer

~40 s

A `CancelToken` is a one-shot switch you pass as `cancelToken:` to any dio call; one token can be shared by several requests. `token.cancel([reason])` completes its `whenCancel` future, dio aborts every request bound to it, and each caller gets a `DioException` whose type is `DioExceptionType.cancel` (check with `CancelToken.isCancel(e)`). Typical uses are cancelling a screen's requests in `dispose` and dropping the previous request when a search query changes. A token cannot be reset: `isCancelled` stays `true`, and dio checks it before sending, so every new request that uses the old token throws the stored cancel error at once. Create a fresh token for each screen instance or each keystroke. Cancelling stops the client waiting; it does not undo work the server already did. `package:http` has had a similar mechanism since 1.5: `AbortableRequest` with an `abortTrigger` future.

code

dart · 27 lines
dart
import 'package:dio/dio.dart';

class ClassSearch {
  ClassSearch(this._dio);

  final Dio _dio;
  CancelToken? _inFlight;

  Future<List<dynamic>?> search(String query) async {
    _inFlight?.cancel('superseded');
    final token = CancelToken(); // never reuse a cancelled token
    _inFlight = token;
    try {
      final response = await _dio.get<List<dynamic>>(
        '/classes',
        queryParameters: {'q': query},
        cancelToken: token,
      );
      return response.data;
    } on DioException catch (e) {
      if (CancelToken.isCancel(e)) return null; // a newer query won
      rethrow;
    }
  }

  void dispose() => _inFlight?.cancel('disposed');
}

go deeper

for a junior

Know that a CancelToken passed to dio lets you abort requests, for example when leaving a screen.

for a middle

Explain how cancel surfaces as DioExceptionType.cancel, how CancelToken.isCancel filters it, and why a cancelled token must be replaced.

for a senior

Scope tokens to screens and queries, keep cancellation away from non-idempotent calls, and reconcile state when a write's outcome is unknown.

for a principal

Set the team rule for which calls are cancellable and how cancelled writes are reconciled, so behaviour is consistent across features.

## What a `CancelToken` is `CancelToken` is dio's cancellation handle. It wraps a `Completer`: - `cancel([Object? reason])` completes it with a `DioException` built for cancellation; - `whenCancel` is the future dio listens to while a request is in flight; - `isCancelled` and `cancelError` expose the state afterwards; - `CancelToken.isCancel(e)` is a static helper that checks `e.type == DioExceptionType.cancel`. You pass it to any request method, `dio.get(path, cancelToken: token)`, and **one token can control many requests**. ## What happens when you cancel 1. `cancel` completes `whenCancel`. 2. dio races each bound request against that future, so the pending call throws the cancel `DioException`. 3. Your `catch` sees `DioExceptionType.cancel`; in most UIs you ignore it, because the user left or changed the query. Cancelling a token twice is harmless; dio only logs a warning if the second call gives a different reason. ## The reuse trap A token is **one-shot**. Before sending, dio checks `cancelToken.isCancelled` and, if it is `true`, throws the stored cancel error immediately without touching the network. Code that keeps one token in a field, cancels it in `dispose` or on a query change, and then reuses it for the next request will see every later request fail instantly with `cancel`. The fix is structural: - **Per screen**: create the token in `initState` (or the view model's constructor) and cancel it in `dispose`. A new screen instance gets a new token. - **Per search**: on each new query, cancel the previous token and assign a **new** `CancelToken()` before the next request. ## Gym-app examples | Situation | Token strategy | |---|---| | Class-search box that queries as the user types | New token per query; cancel the previous one first | | Booking-history screen loading three endpoints | One token shared by the three requests, cancelled in `dispose` | | Large workout-video download | Its own token behind a Cancel button | | Check-in POST | Usually no cancellation once sent: the server may already have recorded it | The last row matters. Cancelling stops the **client** from waiting; it cannot tell whether the server processed the request. For non-idempotent calls the UI should not assume "cancelled" means "did not happen". ## Cancellation in `package:http` Since `package:http` 1.5, requests that implement `Abortable`, such as `AbortableRequest`, accept an `abortTrigger` future. Completing it (typically through a `Completer`) aborts the request: before sending, `send` fails with `RequestAbortedException`; while streaming, the exception is injected into the response stream. Clients that do not support aborting ignore the trigger, so check the client you use. ## Downloads and progress `dio.download(url, savePath, cancelToken: token, onReceiveProgress: ...)` accepts the same token, which is how a Cancel button on a workout-video download works. Its `deleteOnError` parameter defaults to `true`, so a cancelled download does not leave a partial file behind. ## Cancelling from state holders Whatever owns the request should own the token. A `State` object cancels in `dispose`; a view model or controller cancels in its own dispose hook when the screen that created it goes away. Passing one app-wide token around makes it impossible to cancel one screen's work without cancelling everything. ## Cancellation versus timeouts A timeout is dio deciding the request took too long (`connectionTimeout`, `sendTimeout`, `receiveTimeout`); cancellation is your code deciding the result is no longer wanted. They surface as different `DioExceptionType` values, which lets the UI show "network slow" for one and nothing for the other.

  • Why does every request fail immediately with a cancel error after the user returns to a screen?
    The screen reuses a `CancelToken` that was cancelled in an earlier `dispose`. dio checks `isCancelled` before sending and throws the stored error. Create the token per screen instance or per request batch.
  • Does cancelling a check-in POST guarantee the check-in was not recorded?
    No. Cancellation only stops the client waiting for the response; if the request already reached the server it may have been processed. Treat the outcome as unknown and reconcile, for example by reloading the member's check-ins.
  • How do you cancel a package:http request?
    Use a request type that implements `Abortable`, such as `AbortableRequest`, available since http 1.5, and pass an `abortTrigger` future, usually from a `Completer`. Completing it aborts the request, and the caller sees a `RequestAbortedException`.

saying these in an interview costs you the question

  • A cancelled CancelToken resets itself for the next request.
  • Each request needs its own token; tokens cannot be shared.
  • Cancelling a POST guarantees the server did nothing.
  • Cancelled requests surface as a connectionTimeout error.
  • package:http offers no way to abort a request.